merged from gramsrv upstream

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

42
.dockerignore Normal file
View file

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

View file

@ -317,7 +317,7 @@ TELESRV_MTPROTO_RPC_MAX_INFLIGHT=32
TELESRV_MTPROTO_RPC_QUEUE_SIZE=64
TELESRV_MTPROTO_RPC_TIMEOUT=30s
TELESRV_MTPROTO_RPC_GLOBAL_WORKERS=256
TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=8192
TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=32768
TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES=536870912
# Metadata-only rpc_result receipt budgets: global >= auth >= session. ACK deletes immediately;
# 331s is only the no-ACK horizon. Payloads live solely in the logical-session outbound budget.
@ -474,6 +474,10 @@ TELESRV_VERIFICATION_BOT_RATE_WINDOW=1m
# rows. Interval must be positive; batch must be 1..500.
TELESRV_VERIFICATION_NOTIFY_INTERVAL=15s
TELESRV_VERIFICATION_NOTIFY_BATCH=50
TELESRV_BROADCAST_WORKER_INTERVAL=3s
TELESRV_BROADCAST_WORKER_LEASE=30s
TELESRV_BROADCAST_MATERIALIZE_BATCH=200
TELESRV_BROADCAST_DELIVERY_BATCH=50
# Applications one applicant may keep open at once; 0 disables the cap, maximum
# is 50.
TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER=3

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

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

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

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

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

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

91
Dockerfile Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -10,6 +10,7 @@ import (
"os"
"os/signal"
"runtime"
runtimemetrics "runtime/metrics"
"strconv"
"strings"
"syscall"
@ -60,6 +61,7 @@ import (
"telesrv/internal/app/userprojection"
"telesrv/internal/app/users"
verificationapp "telesrv/internal/app/verification"
welcomemessagesapp "telesrv/internal/app/welcomemessages"
"telesrv/internal/botapi"
"telesrv/internal/config"
"telesrv/internal/domain"
@ -79,6 +81,7 @@ import (
"telesrv/internal/store/redisstore"
"telesrv/internal/telegramloginhttp"
"telesrv/internal/turnsrv"
"telesrv/internal/updatecdn"
"telesrv/internal/web"
)
@ -273,8 +276,9 @@ func startDebugServer(ctx context.Context, addr string, metricsHandler http.Hand
func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
return []obsmetrics.GaugeSample{
samples := []obsmetrics.GaugeSample{
{Name: "telesrv_go_goroutines", Value: float64(runtime.NumGoroutine())},
{Name: "telesrv_go_scheduler_busy_seconds", Value: goSchedulerBusySeconds()},
{Name: "telesrv_go_heap_alloc_bytes", Value: float64(mem.HeapAlloc)},
{Name: "telesrv_go_heap_inuse_bytes", Value: float64(mem.HeapInuse)},
{Name: "telesrv_go_heap_objects", Value: float64(mem.HeapObjects)},
@ -283,6 +287,28 @@ func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
{Name: "telesrv_go_gc_cycles", Value: float64(mem.NumGC)},
{Name: "telesrv_go_gc_pause_seconds", Value: time.Duration(mem.PauseTotalNs).Seconds()},
}
if value, ok := processCPUSeconds(); ok {
samples = append(samples, obsmetrics.GaugeSample{Name: "telesrv_process_cpu_seconds", Value: value})
}
return samples
}
// goSchedulerBusySeconds is a Go scheduler-class estimate. The runtime
// documentation explicitly warns that CPU-class values are overestimates and
// are not comparable to operating-system process CPU time, so capacity reports
// use telesrv_process_cpu_seconds instead.
func goSchedulerBusySeconds() float64 {
samples := []runtimemetrics.Sample{
{Name: "/cpu/classes/total:cpu-seconds"},
{Name: "/cpu/classes/idle:cpu-seconds"},
}
runtimemetrics.Read(samples)
total := samples[0].Value.Float64()
idle := samples[1].Value.Float64()
if total <= idle {
return 0
}
return total - idle
}
func mtprotoRuntimeGaugeSamples(snapshot mtprotoedge.RuntimeSnapshot) []obsmetrics.GaugeSample {
@ -303,6 +329,15 @@ func mtprotoRuntimeGaugeSamples(snapshot mtprotoedge.RuntimeSnapshot) []obsmetri
{Name: "telesrv_mtproto_inbound_rpc_ready_connections", Value: float64(snapshot.InboundRPCReadyConnections)},
{Name: "telesrv_mtproto_inbound_rpc_task_limit", Value: float64(snapshot.InboundRPCMaxTasks)},
{Name: "telesrv_mtproto_inbound_rpc_byte_limit", Value: float64(snapshot.InboundRPCMaxBytes)},
{Name: "telesrv_mtproto_rpc_delivery_hook_workers", Value: float64(snapshot.RPCDeliveryHookWorkers)},
{Name: "telesrv_mtproto_rpc_delivery_hook_capacity", Value: float64(snapshot.RPCDeliveryHookCapacity)},
{Name: "telesrv_mtproto_rpc_delivery_hook_reserved", Value: float64(snapshot.RPCDeliveryHookReserved)},
{Name: "telesrv_mtproto_rpc_delivery_hook_queued", Value: float64(snapshot.RPCDeliveryHookQueued)},
{Name: "telesrv_mtproto_rpc_delivery_hook_running", Value: float64(snapshot.RPCDeliveryHookRunning)},
{Name: "telesrv_mtproto_rpc_delivery_hook_completed_total", Value: float64(snapshot.RPCDeliveryHookCompleted)},
{Name: "telesrv_mtproto_rpc_delivery_hook_rejected_total", Value: float64(snapshot.RPCDeliveryHookRejected)},
{Name: "telesrv_mtproto_rpc_delivery_hook_panics_total", Value: float64(snapshot.RPCDeliveryHookPanics)},
{Name: "telesrv_mtproto_rpc_delivery_hook_duration_seconds_total", Value: snapshot.RPCDeliveryHookDurationSeconds},
{Name: "telesrv_mtproto_inbound_frame_bytes", Value: float64(snapshot.InboundFrameBytes)},
{Name: "telesrv_mtproto_inbound_frame_byte_limit", Value: float64(snapshot.InboundFrameMaxBytes)},
{Name: "telesrv_mtproto_outbound_tracked_bytes", Labels: []obsmetrics.Label{{Name: "kind", Value: "body"}}, Value: float64(snapshot.OutboundTrackedBytes)},
@ -445,15 +480,20 @@ type rpcProjectionVerificationNotifier struct {
invalidator interface {
InvalidateRPCProjectionReadModelForUser(userID int64)
InvalidateRPCProjectionReadModelForChannel(channelID int64)
InvalidatePeerIdentityReadModel(domain.Peer)
}
users storepkg.UserCache
log *zap.Logger
users storepkg.UserCache
peerIdentity bool
log *zap.Logger
}
func (n rpcProjectionVerificationNotifier) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
if n.invalidator == nil {
return nil
}
if n.peerIdentity {
n.invalidator.InvalidatePeerIdentityReadModel(peer)
}
switch peer.Type {
case domain.PeerTypeUser:
n.invalidator.InvalidateRPCProjectionReadModelForUser(peer.ID)
@ -570,6 +610,15 @@ func run(logger *zap.Logger) error {
zap.Bool("schema_dirty", migrationStatus.Dirty),
zap.Bool("schema_empty", migrationStatus.Empty),
)
blobRuntimeLock, err := postgres.AcquireBlobRuntimeLock(ctx, cfg.PostgresDSN)
if err != nil {
return fmt.Errorf("acquire blob runtime lock: %w", err)
}
defer func() {
if err := blobRuntimeLock.Close(); err != nil {
logger.Error("release blob runtime lock", zap.Error(err))
}
}()
pool, err := postgres.Open(ctx, cfg.PostgresDSN,
postgres.WithMaxConns(cfg.PostgresMaxConns),
postgres.WithMinConns(cfg.PostgresMinConns),
@ -649,7 +698,8 @@ func run(logger *zap.Logger) error {
if cfg.TelegramLoginEnabled {
telegramLoginHTTPHandler, err = telegramloginhttp.NewHandler(telegramloginhttp.Config{
Service: telegramLoginService, Tokens: telegramLoginIDTokens,
Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName,
BotUsernames: postgres.NewUserStore(pool),
Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName,
Logger: logger.Named("telegram-login-http"), TrustedProxyCIDRs: cfg.TelegramLoginTrustedProxyCIDRs,
AllowHTTP: cfg.TelegramLoginAllowHTTP,
})
@ -662,51 +712,157 @@ func run(logger *zap.Logger) error {
}
authKeyStore := postgres.NewAuthKeyStore(pool)
authKeyGetBatchStore, err := postgres.NewBatchedAuthKeyStore(
authKeyStore,
postgres.AuthKeyGetBatchConfig{
MaxSize: cfg.AuthKeyGetBatchMax, MaxWait: cfg.AuthKeyGetBatchWait,
QueueSize: cfg.AuthKeyGetBatchQueue, QueryTimeout: cfg.AuthKeyGetBatchTimeout,
},
)
if err != nil {
return err
}
defer authKeyGetBatchStore.Close()
authKeySessionLayerStore, err := postgres.NewBatchedAuthKeySessionLayerStore(
authKeyStore,
postgres.AuthKeySessionLayerBatchConfig{
MaxSize: cfg.LayerAdvanceBatchMax, MaxWait: cfg.LayerAdvanceBatchWait,
QueueSize: cfg.LayerAdvanceBatchQueue, QueryTimeout: cfg.LayerAdvanceBatchTimeout,
},
)
if err != nil {
return err
}
defer authKeySessionLayerStore.Close()
userStore := postgres.NewUserStore(pool)
authzStore := postgres.NewAuthorizationStore(pool)
adminStore := postgres.NewAdminStore(pool)
updateStateStore := postgres.NewUpdateStateStore(pool)
updateEventStore := postgres.NewUpdateEventStore(pool, postgres.WithUpdateEventLogger(logger.Named("store").Named("updates")))
phoneChangeStore := postgres.NewPhoneChangeStore(pool)
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(postgres.NewReadModelVersionStore(pool), 0, 0)
readModelVersionBatchStore, err := storepkg.NewBatchedReadModelVersionStore(
postgres.NewReadModelVersionStore(pool),
storepkg.ReadModelVersionBatchConfig{
MaxKeys: cfg.ReadModelVersionBatchMaxKeys, MaxWait: cfg.ReadModelVersionBatchWait,
QueueSize: cfg.ReadModelVersionBatchQueue, QueryTimeout: cfg.ReadModelVersionBatchTimeout,
},
)
if err != nil {
return err
}
defer readModelVersionBatchStore.Close()
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(
readModelVersionBatchStore,
0,
cfg.ReadModelVersionCacheMaxEntries,
)
dialogListSnapshotCache := redisstore.NewDialogListSnapshotCache(rdb, cfg.DialogListSnapshotRedisTTL)
activeChannelIDsPageCache := redisstore.NewActiveChannelIDsPageCache(rdb, cfg.ActiveChannelIDsRedisTTL)
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout))
bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool)
bootstrapUpdateStore, err := postgres.NewBatchedBootstrapUpdateJobStore(
postgres.NewBootstrapUpdateJobStore(pool),
postgres.BootstrapReadyBatchConfig{
MaxSize: cfg.BootstrapReadyBatchMax, MaxWait: cfg.BootstrapReadyBatchWait,
QueueSize: cfg.BootstrapReadyBatchQueue, QueryTimeout: cfg.BootstrapReadyBatchTimeout,
Metrics: metricRegistry,
},
)
if err != nil {
return err
}
defer bootstrapUpdateStore.Close()
botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool)
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
ephemeralStore := redisstore.NewEphemeralMessageStore(rdb)
ephemeralReportStore := postgres.NewEphemeralReportStore(pool)
welcomeMessageStore := postgres.NewWelcomeMessageStore(pool)
moderationReportStore := postgres.NewModerationReportStore(pool)
authDeliveryReportStore := postgres.NewAuthDeliveryReportStore(pool)
clientTelemetryStore := postgres.NewClientTelemetryStore(pool)
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
secretChatIDAllocator := redisstore.NewSecretChatIDAllocator(rdb, postgres.NewSecretChatIDCounterSource(pool))
contactStore := userprojection.NewCachedContactStore(postgres.NewContactStore(pool), 0)
reverseContactStore, err := storepkg.NewBatchedReverseContactStore(
postgres.NewContactStore(pool),
storepkg.ReverseContactBatchConfig{
MaxPairs: cfg.ContactReverseBatchMaxPairs, MaxWait: cfg.ContactReverseBatchWait,
QueueSize: cfg.ContactReverseBatchQueue, QueryTimeout: cfg.ContactReverseBatchTimeout,
},
)
if err != nil {
return err
}
defer reverseContactStore.Close()
contactStore := userprojection.NewCachedContactStoreWithMaxViewers(
reverseContactStore,
0,
cfg.ContactSnapshotCacheMaxViewers,
)
dialogStore := postgres.NewDialogStore(pool)
chatlistStore := postgres.NewChatlistStore(pool)
messageStore := postgres.NewMessageStore(pool,
postgres.WithMessageAllocators(boxIDAllocator),
postgres.WithMessageLogger(logger.Named("store").Named("messages")))
broadcastStore := postgres.NewBroadcastStore(pool)
broadcastService := broadcastapp.NewService(broadcastStore,
broadcastapp.WithMessageSender(messageStore),
broadcastapp.WithLogger(logger.Named("broadcast")))
// 共享频道行/成员缓存 + 统一 read-model LISTEN/NOTIFY 实时失效:消除高频「逐 RPC
// 解析频道/成员」在客户端重连同步突发里重复读同一行的放大。
channelRowCache := postgres.NewChannelRowCache(cfg.ChannelRowCacheMaxEntries)
channelTopMessageCache := postgres.NewChannelTopMessageCache(cfg.ChannelTopMessageCacheMaxEntries)
channelMemberCache := postgres.NewChannelMemberCache(cfg.ChannelMemberCacheMaxEntries)
channelDialogCache := postgres.NewChannelDialogCache(cfg.ChannelDialogCacheMaxEntries)
channelDifferenceCache := postgres.NewChannelDifferenceBaseCache(
cfg.ChannelDifferenceCacheMaxEntries,
cfg.ChannelDifferenceCacheMaxBytes,
cfg.ChannelDifferenceCacheTTL,
)
channelBoostCache := postgres.NewChannelBoostCache(cfg.ChannelBoostCacheMaxEntries, cfg.ChannelBoostCacheTTL)
channelStore := postgres.NewChannelStore(pool,
postgres.WithChannelAllocators(channelIDAllocator, channelMessageIDAllocator),
postgres.WithChannelLogger(logger.Named("store").Named("channels")),
postgres.WithChannelRowCache(channelRowCache),
postgres.WithChannelTopMessageCache(channelTopMessageCache),
postgres.WithChannelMemberCache(channelMemberCache),
postgres.WithChannelDialogCache(channelDialogCache),
postgres.WithChannelDifferenceBaseCache(channelDifferenceCache),
postgres.WithChannelBoostCache(channelBoostCache))
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator)
activeChannelIDsPageBatcher, err := postgres.NewActiveChannelIDsPageBatcher(
channelStore,
postgres.ActiveChannelIDsBatchConfig{
MaxSize: cfg.ActiveChannelIDsBatchMax, MaxWait: cfg.ActiveChannelIDsBatchWait,
QueueSize: cfg.ActiveChannelIDsBatchQueue, QueryTimeout: cfg.ActiveChannelIDsBatchTimeout,
Metrics: metricRegistry,
},
)
if err != nil {
return err
}
defer activeChannelIDsPageBatcher.Close()
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
snapshot := channelDifferenceCache.Snapshot()
return []obsmetrics.GaugeSample{
{Name: "telesrv_channel_difference_cache_entries", Value: float64(snapshot.Entries)},
{Name: "telesrv_channel_difference_cache_weight_bytes", Value: float64(snapshot.Weight)},
{Name: "telesrv_channel_difference_cache_hits", Value: float64(snapshot.Hits)},
{Name: "telesrv_channel_difference_cache_misses", Value: float64(snapshot.Misses)},
{Name: "telesrv_channel_difference_cache_loads", Value: float64(snapshot.Loads)},
{Name: "telesrv_channel_difference_cache_load_errors", Value: float64(snapshot.LoadErrors)},
}
})
communityCatalogCache := postgres.NewCommunityCatalogCache()
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator,
postgres.WithCommunityCatalogCache(communityCatalogCache))
pollStore := postgres.NewPollStore(pool)
mediaStore := postgres.NewMediaStore(pool)
// 头像投影缓存:所有 projector 共用一层短 TTL owner→头像缓存消除高频「返回用户」RPC
// 每次投影对每批 owner 固定 2 次的 CurrentProfilePhotosKind PG 查询。
cachedPhotos := userprojection.NewCachedPhotoProvider(mediaStore, userprojection.DefaultPhotoCacheTTL)
// 头像投影缓存:所有 projector 共用 owner→头像正/负 LRU。profile_photo NOTIFY
// 精确失效负责正常新鲜度,长 TTL 只覆盖漏通知,避免登录 ramp 周期性重查稳定负值。
cachedPhotos := userprojection.NewCachedPhotoProviderWithMaxEntries(
mediaStore,
cfg.ProfilePhotoCacheTTL,
cfg.ProfilePhotoCacheMaxEntries,
)
privacyStore := privacyapp.NewCachedPrivacyStore(postgres.NewPrivacyStore(pool), 0)
storyStore := postgres.NewStoryStore(pool)
// Transient upload-part scratch storage always stays on local disk
@ -902,6 +1058,11 @@ func run(logger *zap.Logger) error {
Commands: adminStore,
Restrictions: adminStore,
})
userProjectionFacts := userprojection.NewDurableUserProjectionFacts(
adminService,
readModelVersionStore,
cfg.UserProjectionFactCacheMaxEntries,
)
storageRetentionMaxAge := cfg.StorageRetentionMaxAge
if !cfg.StorageRetentionEnable {
storageRetentionMaxAge = 0
@ -932,7 +1093,7 @@ func run(logger *zap.Logger) error {
contactsService := contacts.NewService(contactStore, userStore).Configure(
contacts.WithPhotoProvider(cachedPhotos),
contacts.WithPrivacyEvaluator(privacyService),
contacts.WithAccountFreezeProvider(adminService),
contacts.WithAccountFreezeProvider(userProjectionFacts),
contacts.WithReadModelVersions(readModelVersionStore),
contacts.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification),
)
@ -1036,6 +1197,33 @@ func run(logger *zap.Logger) error {
botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow),
botsapp.WithPublicBaseURL(cfg.PublicBaseURL),
botsapp.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification))
// The built-in ChatBot and StickersBot are seeded with the default product
// name in their bio (users.about) and description (bots.description). Align
// them with the active branding on startup so the seeded "telesrv" text is
// replaced. SetBotInfo writes both fields; the sync is a no-op when the text
// already matches.
for _, botID := range []int64{domain.ChatBotUserID, domain.StickersBotUserID} {
var wantAbout, wantDesc string
switch botID {
case domain.ChatBotUserID:
wantAbout = domain.ChatBotDescription()
wantDesc = wantAbout
case domain.StickersBotUserID:
wantAbout = domain.StickersBotDescription()
wantDesc = wantAbout
}
if _, curAbout, curDesc, err := botsService.GetBotInfo(ctx, botID); err == nil && curAbout == wantAbout && curDesc == wantDesc {
continue
}
if _, err := botsService.SetBotInfo(ctx, botID, domain.BotInfoUpdate{
SetAbout: true,
About: wantAbout,
SetDescription: true,
Description: wantDesc,
}); err != nil {
logger.Warn("sync bot branding", zap.Int64("bot", botID), zap.Error(err))
}
}
groupCallStore := postgres.NewGroupCallStore(pool)
groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL))
// 群通话媒体面:内嵌 pion SFUM1+。SFU 的 liveness reporter 把媒体面存活
@ -1120,7 +1308,7 @@ func run(logger *zap.Logger) error {
// 私聊端对端加密Secret Chat握手状态机 + qts 投递队列(盲中继)。
secretChatStore := postgres.NewSecretChatStore(pool)
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator)
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore)
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
// 同属进程内一次性凭据,不跨实例)。
passkeyStore := postgres.NewPasskeyStore(pool)
@ -1129,7 +1317,7 @@ func run(logger *zap.Logger) error {
passkeyapp.WithAllowedOrigins(cfg.PasskeyAllowedOrigins))
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
themeService := themesapp.NewService(postgres.NewThemeStore(pool))
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(adminService), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), users.WithReservedUsernames(cfg.ReservedUsernames))
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(userProjectionFacts), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), users.WithReservedUsernames(cfg.ReservedUsernames))
privacyService.ConfigureReadModels(usersService, channelStore)
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
botsService.SetAIChatGenerator(aiComposeService)
@ -1137,9 +1325,21 @@ func run(logger *zap.Logger) error {
dialogs.WithContactStore(contactStore),
dialogs.WithPhotoProvider(cachedPhotos),
dialogs.WithPrivacyEvaluator(privacyService),
dialogs.WithAccountFreezeProvider(adminService),
dialogs.WithAccountFreezeProvider(userProjectionFacts),
dialogs.WithPremiumChecker(usersService.PremiumActive),
dialogs.WithReadModelVersions(readModelVersionStore),
dialogs.WithDialogHydrationCaches(
cfg.DialogPrivatePeerCacheMaxEntries,
cfg.DialogPrivatePeerCacheMaxBytes,
cfg.DialogDraftCacheMaxEntries,
cfg.DialogDraftCacheMaxBytes,
),
dialogs.WithDialogListSnapshotCache(
cfg.DialogListSnapshotCacheMaxEntries,
cfg.DialogListSnapshotCacheMaxHeaders,
cfg.DialogListSnapshotCacheTTL,
),
dialogs.WithSharedDialogListSnapshotCache(dialogListSnapshotCache),
)
// 编译期保证 *users.Service 满足 channel fan-out 跨 viewer 投影预热的可选能力;签名漂移会在
// 这里立刻断编译,而非在运行时静默退化回 O(viewer) 逐 viewer 投影。
@ -1147,11 +1347,19 @@ func run(logger *zap.Logger) error {
channelsService := channelapp.NewService(channelStore,
channelapp.WithBotProfileResolver(botsService),
channelapp.WithReadModelVersions(readModelVersionStore),
channelapp.WithActiveChannelIDsReadModel(
activeChannelIDsPageCache,
activeChannelIDsPageBatcher,
cfg.ActiveChannelIDsCacheMaxEntries,
cfg.ActiveChannelIDsCacheTTL,
metricRegistry,
),
channelapp.WithSendPermissionChecker(adminService),
channelapp.WithReservedUsernames(cfg.ReservedUsernames),
)
communitiesService := communitiesapp.NewService(communityStore)
ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService)
welcomeMessageService := welcomemessagesapp.NewService(welcomeMessageStore, channelsService)
storiesService := storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService))
chatlistsService := chatlistsapp.NewService(
chatlistStore,
@ -1164,7 +1372,7 @@ func run(logger *zap.Logger) error {
messageapp.WithContactStore(contactStore),
messageapp.WithPhotoProvider(cachedPhotos),
messageapp.WithPrivacyEvaluator(privacyService),
messageapp.WithAccountFreezeProvider(adminService),
messageapp.WithAccountFreezeProvider(userProjectionFacts),
messageapp.WithReadModelVersions(readModelVersionStore),
messageapp.WithBotResponder(botsService),
messageapp.WithSendPermissionChecker(adminService),
@ -1191,7 +1399,7 @@ func run(logger *zap.Logger) error {
dialogStore,
newTranslationOptions(cfg, rateLimiter, logger)...,
)
authService := auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode,
authService := auth.NewService(userStore, authzStore, codeStore, authKeyGetBatchStore, tempAuthKeyStore, cfg.DevAuthCode,
auth.WithLoginMessages(messageStore, dialogStore),
auth.WithLoginCodeDelivery(messageStore),
auth.WithPasswords(passwordStore),
@ -1285,6 +1493,14 @@ func run(logger *zap.Logger) error {
logger.Info("default verifier seed complete", zap.Int64("bot_id", domain.VerifierBotUserID))
}
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
var appUpdateResolver updatecdn.Resolver
if cfg.UpdateServiceURL != "" {
client, err := updatecdn.NewClient(cfg.UpdateServiceURL, cfg.UpdateRequestTimeout)
if err != nil {
return fmt.Errorf("initialize update service client: %w", err)
}
appUpdateResolver = client
}
router := rpc.New(rpc.Config{
DC: cfg.DC,
DefaultCountryCode: cfg.DefaultCountryCode,
@ -1304,17 +1520,29 @@ func run(logger *zap.Logger) error {
GroupCallMaxParticipants: cfg.GroupCallMaxParticipants,
RtmpIngestURL: cfg.LiveStreamRtmpURL,
PublicBaseURL: cfg.PublicBaseURL,
UpdatePublicURL: cfg.UpdatePublicURL,
PublicAppScheme: cfg.PublicAppScheme,
PublicAppLinkBase: cfg.PublicAppLinkBase,
// PFS temp→perm 解析缓存显式撤销会清缓存并断开连接re-bind 即时失效;
// 配置 TTL 只承担跨进程/异常失效兜底,避免大连接数周期性打满 PG。
TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL,
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL,
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
PeerIdentityCacheMaxEntries: cfg.PeerIdentityCacheMaxEntries,
StoryActivePeerCacheMaxEntries: cfg.StoryActivePeerCacheMaxEntries,
StoryHiddenListCacheMaxEntries: cfg.StoryHiddenListCacheMaxEntries,
StoryHiddenListCacheMaxBytes: cfg.StoryHiddenListCacheMaxBytes,
PresenceLastSeenBatchMax: cfg.PresenceLastSeenBatchMax,
PresenceLastSeenBatchWait: cfg.PresenceLastSeenBatchWait,
PresenceLastSeenBatchQueue: cfg.PresenceLastSeenBatchQueue,
PresenceLastSeenBatchTimeout: cfg.PresenceLastSeenBatchTimeout,
PresenceLastSeenDrainTimeout: cfg.PresenceLastSeenDrainTimeout,
}, rpc.Deps{
Auth: authService,
AuthDeliveryReports: authDeliveryReportService,
ClientTelemetry: clientTelemetryService,
AuthKeySessionLayers: authKeyStore,
AuthKeySessionLayers: authKeySessionLayerStore,
ReadModelVersions: readModelVersionStore,
UserProjectionFacts: userProjectionFacts,
Account: accountService,
Privacy: privacyService,
Help: help.NewService(helpStore, helpStore,
@ -1323,73 +1551,77 @@ func run(logger *zap.Logger) error {
help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
help.WithAccountFreezeProvider(adminService),
),
AccountFreeze: adminService,
AICompose: aiComposeService,
Ephemeral: ephemeralService,
EphemeralPush: ephemeralStore,
Moderation: moderationService,
Users: usersService,
Usernames: usernamesService,
BotVerifications: botVerificationService,
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
Updates: updatesService,
BootstrapUpdates: bootstrapUpdateStore,
BotAPIUpdates: botAPIUpdateStore,
BotCallbacks: botCallbackStore,
Contacts: contactsService,
Dialogs: dialogsService,
Chatlists: chatlistsService,
Messages: messagesService,
Translation: translationService,
Channels: channelsService,
Communities: communitiesService,
Files: filesService,
PremiumPromo: filesService,
Bots: botsService,
ServiceBotCallbacks: botsService,
ServiceBotInlineResults: botsService,
Polls: pollsapp.NewService(pollStore),
Stories: storiesService,
Phone: phoneService,
SecretChats: secretChatService,
Passkey: passkeyService,
Themes: themeService,
GroupCalls: groupCallsService,
LiveStreams: liveStreamDep(liveStreamService),
SFU: sfuService,
TURN: turnService,
LangPack: langPackService,
Sessions: activeSessions,
Metrics: metricRegistry,
Inline: inlineRegistryStore,
Limiter: rateLimiter,
AppUpdates: appUpdateResolver,
AccountFreeze: userProjectionFacts,
AccountFreezeNotifications: adminService,
AICompose: aiComposeService,
Ephemeral: ephemeralService,
EphemeralPush: ephemeralStore,
WelcomeMessages: welcomeMessageService,
Moderation: moderationService,
Users: usersService,
Usernames: usernamesService,
BotVerifications: botVerificationService,
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
Updates: updatesService,
BootstrapUpdates: bootstrapUpdateStore,
BotAPIUpdates: botAPIUpdateStore,
BotCallbacks: botCallbackStore,
Contacts: contactsService,
Dialogs: dialogsService,
Chatlists: chatlistsService,
Messages: messagesService,
Translation: translationService,
Channels: channelsService,
Communities: communitiesService,
Files: filesService,
PremiumPromo: filesService,
Bots: botsService,
ServiceBotCallbacks: botsService,
ServiceBotInlineResults: botsService,
Polls: pollsapp.NewService(pollStore),
Stories: storiesService,
Phone: phoneService,
SecretChats: secretChatService,
Passkey: passkeyService,
Themes: themeService,
GroupCalls: groupCallsService,
LiveStreams: liveStreamDep(liveStreamService),
SFU: sfuService,
TURN: turnService,
LangPack: langPackService,
Sessions: activeSessions,
Metrics: metricRegistry,
Inline: inlineRegistryStore,
Limiter: rateLimiter,
}, logger.Named("rpc"), clock.System)
readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{
ReadModelVersions: readModelVersionStore,
ChannelRows: channelRowCache,
ChannelMembers: channelMemberCache,
ChannelDialogs: channelDialogCache,
ChannelBoosts: channelBoostCache,
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
Dialogs: dialogsService,
Privacy: privacyService,
ProfilePhotos: cachedPhotos,
Stories: router,
ChannelFullBots: router,
ChannelBotMembers: channelsService,
ChannelMediaCounts: channelsService,
PrivateMediaCounts: messagesService,
RPCProjections: router,
BaseUsers: userCache,
BotProfiles: botsService,
AccountSettings: router,
ReadModelVersions: readModelVersionStore,
ChannelRows: channelRowCache,
ChannelTopMessages: channelTopMessageCache,
CommunityCatalog: communityCatalogCache,
ChannelMembers: channelMemberCache,
ChannelDialogs: channelDialogCache,
ChannelDifferences: channelDifferenceCache,
ChannelBoosts: channelBoostCache,
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
Dialogs: dialogsService,
Privacy: privacyService,
ProfilePhotos: cachedPhotos,
Stories: router,
ChannelFullBots: router,
ChannelBotMembers: channelsService,
ChannelMediaCounts: channelsService,
PrivateMediaCounts: messagesService,
RPCProjections: router,
PeerIdentities: router,
BaseUsers: userCache,
BotProfiles: botsService,
AccountSettings: router,
UserProjectionFacts: userProjectionFacts,
}, logger.Named("store").Named("read-model-listener"))
go readModelListener.Run(ctx)
activeSessions.SetLifecycleObserver(router)
broadcastStore := postgres.NewBroadcastStore(pool)
broadcastService := broadcastapp.NewService(broadcastStore,
broadcastapp.WithMessageSender(messageStore),
broadcastapp.WithLogger(logger.Named("broadcast")))
adminService.Configure(adminapp.Dependencies{
Auth: authService,
Revoker: router,
@ -1453,9 +1685,10 @@ func run(logger *zap.Logger) error {
if notifier, ok := any(router).(botverificationapp.PeerNotifier); ok {
botVerificationService.SetPeerNotifier(compositeBotVerificationNotifier{
cache: rpcProjectionVerificationNotifier{
invalidator: router,
users: userCache,
log: verificationLogger,
invalidator: router,
users: userCache,
peerIdentity: true,
log: verificationLogger,
},
edge: notifier,
})
@ -1474,7 +1707,9 @@ func run(logger *zap.Logger) error {
// not wait on however long sending to all of them takes.
go broadcastapp.NewWorker(broadcastService, logger.Named("broadcast").Named("delivery"),
cfg.BroadcastWorkerInterval, cfg.BroadcastWorkerBatch).Run(ctx)
moderationActionOptions := []moderationapp.ActionExecutorOption{}
moderationActionOptions := []moderationapp.ActionExecutorOption{
moderationapp.WithAccountDeletionNotifier(router),
}
if cfg.PublicLinkWebAddr != "" {
moderationActionOptions = append(
moderationActionOptions,
@ -1503,6 +1738,7 @@ func run(logger *zap.Logger) error {
rpc.WithOutboxUpdateBuilder(router.BuildOutboxUpdates),
).Run(ctx)
go rpc.NewBootstrapUpdateDispatcher(router, logger.Named("rpc").Named("bootstrap")).Run(ctx)
go rpc.NewWelcomeDeliveryDispatcher(router, welcomeMessageStore, logger.Named("rpc").Named("welcome-delivery")).Run(ctx)
go rpc.NewScheduledDispatcher(router, logger.Named("rpc").Named("scheduled")).Run(ctx)
go rpc.NewSuggestedPostDispatcher(router, logger.Named("rpc").Named("suggested-post")).Run(ctx)
go rpc.NewExpiryDispatcher(router, logger.Named("rpc").Named("expiry")).Run(ctx)
@ -1510,6 +1746,7 @@ func run(logger *zap.Logger) error {
go rpc.NewGroupCallSweepDispatcher(router, logger.Named("rpc").Named("groupcall-sweep"), cfg.GroupCallSweepInterval, cfg.GroupCallCheckTTL).Run(ctx)
go router.RunChannelFanout(ctx)
go router.RunBotAPIEnqueue(ctx)
go router.RunPresenceLastSeenBatch(ctx)
go router.RunPresenceSweeper(ctx, time.Minute)
go activeSessions.RunPendingSweeper(ctx, time.Minute)
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
@ -1567,7 +1804,7 @@ func run(logger *zap.Logger) error {
RSAKey: rsaKey,
IdentityDir: cfg.IdentityDir,
LayerRPC: router,
AuthKeys: authKeyStore,
AuthKeys: authKeyGetBatchStore,
ActiveSessions: activeSessions,
Metrics: metricRegistry,
ObfuscatedTCP: true,
@ -1582,6 +1819,8 @@ func run(logger *zap.Logger) error {
RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers,
RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks,
RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes,
RPCDeliveryHookWorkers: cfg.MTProtoRPCDeliveryHookWorkers,
RPCDeliveryHookMaxPending: cfg.MTProtoRPCDeliveryHookMaxPending,
RPCExecutionMaxEntries: cfg.MTProtoRPCExecutionMaxEntries,
RPCExecutionAuthMaxEntries: cfg.MTProtoRPCExecutionAuthMaxEntries,
RPCExecutionSessionMaxEntries: cfg.MTProtoRPCExecutionSessionMaxEntries,

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -0,0 +1 @@
LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBeEYvLzBNMCsvNVB6Z2ROYWdUWCtKK2RKZ3I3NVpDVHVpRzhpNHg3WXdtSkYramlPCkdDam03WDdCTENhTWMxK2hPWllETDMrR3ZsZS9BS3lrVzFxb3VhQ0pNVngvSCsybDhMRlhMZWxaMlBMYXdUYjgKQTdCbFRxV3pMM2RiNUJ1Z01OV3ppTDlUdWhSOEluMWJ3S1kwN1FWcFI5aW41empBc0FHTEJrK21HdDBEblZ5TQpmMVhvcDJsTENGTm1tMEY0eWtjQWVhTENDSVBiR1dkZGxpTFk4eEVFaEk0R08ybDFVM2taTXdJT2RPbkFHSkZ0CmdVQW9UZStGSFI2RjFzOWFkQ1ZaQjF0ZUwvaGY5UitXbWVrSnd5Z1Z6ME1ZRUg3eTZVNDlUNDUrL1c3T0Y2WDYKZzBXMGoxdVNTcnNZNHFON3R3eGJUYWQ5emRHWjd5cys5ditQdVFJREFRQUJBb0lCQUFqdXFPOHhkczBmU0xNKwpEdDdUdXVUTHcyODhDcElBa0EwS3FSYVZuNXh2NWVqMHk1blR1blZSRDY1WGJvb003b04xREY0THVmQk1nM2FmClk3WjRFRGFwVTdRNEZkdzQ3aFJkcks1ODc4WkxmYUhPUTNaVGZyZ3VGMUZ3WjNDZnhSQ1RsOS8vZStwNTVnK1gKamlYY0tZb2lkZUI3dlY5cUdIR3BFRTdRTHFrSUVpbk1FV05hQjh1dGN0SDdUWGRXYTRweWZJR2lQckhNTjJ6SApoRjQwSWI3bkpBNmtodHpzTkNEU0Q5WG5ibEVORW9kMUU1Z1JzalE5ZkdzaGRCdHBEc1hyTEJGTDdLTUREb1FtCmN6cnQvS3hsWk1wYnRPZno2dWE1ZUtFQkJUdE51dG1WY3AxcTl5K2NRcFBaem5ZaVJUTCtPSnpWZ2Z1SE9PLzAKZWEyai93RUNnWUVBNkk2ZitFTG84QVA3UTRXMHFzc3VYS3ZIRUYvUkVFZngyTHVlaDBsclVoVlIzSWdqYWtGYwpiVFJsTEVRSzhRTmJEQW1OTXVIckVabmptR3U3bnkzek45NHNsQy95Y0k1cGQ5aFFHVndPMjJlcjlvMitNOXcyCkpwQjRRZjhjOFNLL3BIUU45K2pPdEJ4VjJkcmJmZTBvVW9LRXRZeUQ1Y2J0akZUM3lmZUEyQ0VDZ1lFQTJDdW8KUzg0MWtWcHB4MUt3cEw0aTFmZ0dRUHlrYUtyRDRvR1pyWWd2MkZ4VzU1aS9xNUZjWUw2ZkUzN0phTTFwby9SQgp0bkhnMzVOYW5nL3l1Wlh6NkViNEswQ3lMKzhMdWhDSWI2UHhEdnF5Q1hia1hUd0hTVlJBbkdFTVNOM3phLzdGCkZDSkYxQUJXZzhqbEtSZEEvbFJ3cW84UDhaZ0JlQk1xcEtaaDVKa0NnWUJoQ0ltazI3NDN6MkY2dGdKQk5VL2QKNk9yQllVbHBJcXU5ZytOTWpZelREZ1EvSVNxdHZpSGppdllmOXpBZGlnbm1SdUg4ZGhsUUdjYkdKVVYrMEh4bwpOaktoampQNVZPS2ExODNzRnVZNEU5VERwamJUaXJHcGU2UkIzVUZsTjl1QXNjL1dQZlJwWUYxTjdpeWhLV0FtCnRVRE1RNW9ST09TTEpqVFJ0NHl5SVFLQmdRQ1RmeTVsRXYyd0FQMzk5K2o1YjVhN1luRjU5Q2lHRmtaMERiUDcKR05wMGlZVHVuMlhndmQxSFVhbWZGcnA4blBRQTM4L2FtZGN6RmdzVm9KSWdtVFdFZnJBa2F3OXA3M1NUNzJYNApydWJ6THBFK0xmWmh1MnpKVndpQzZ5RURzeFc5MFdkTmRwa29yMVpZczBIUmlNRmJCK2ljSitOY0dEaWdZb3VOCkxzM0t1UUtCZ1FDVEF4Vk03ek5QZ3NmZnVkWlA3Rk1ueTIwTE1SNitGUW83eFJ6NUVsWWFDVmdYTTFOSDRLL3IKeUZrT0NHaE9ONkkvTVVOQlB4V0xFdVpCUmZ5cmx5M2JwM2o3UjYvaExYNGZOeXc5QjNHUXVWeUNIRDJENHRvZAo2SjVWby9Fc2VxcGVlS2E2Q3YvWTJIVkIwa2ZHdzFWQ0MvZ00wMUw4dWVkM2hzcjJMRDJrQ3c9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=

View file

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

View file

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

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

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

View file

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

View file

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

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS broadcast_recipients;
DROP TABLE IF EXISTS broadcasts;

View file

@ -0,0 +1,54 @@
CREATE TABLE broadcasts (
id bigserial PRIMARY KEY,
message text NOT NULL CHECK (message <> '' AND octet_length(message) <= 4096),
target_mode varchar(16) NOT NULL CHECK (target_mode IN ('all', 'selected')),
snapshot_max_user_id bigint NOT NULL DEFAULT 0,
enumeration_cursor_user_id bigint NOT NULL DEFAULT 0,
enumeration_done boolean NOT NULL DEFAULT false,
target_count bigint NOT NULL DEFAULT 0 CHECK (target_count >= 0),
materialized_count bigint NOT NULL DEFAULT 0 CHECK (materialized_count >= 0),
sent_count bigint NOT NULL DEFAULT 0 CHECK (sent_count >= 0),
failed_count bigint NOT NULL DEFAULT 0 CHECK (failed_count >= 0),
created_by varchar(128) NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (enumeration_cursor_user_id >= 0 AND enumeration_cursor_user_id <= snapshot_max_user_id),
CHECK (sent_count + failed_count <= materialized_count)
);
CREATE TABLE broadcast_recipients (
id bigserial PRIMARY KEY,
broadcast_id bigint NOT NULL REFERENCES broadcasts(id) ON DELETE CASCADE,
user_id bigint NOT NULL,
status varchar(16) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'sent', 'failed')),
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
next_attempt_at timestamptz NOT NULL DEFAULT now(),
lease_token varchar(64) NOT NULL DEFAULT '',
lease_until timestamptz,
last_error varchar(500) NOT NULL DEFAULT '',
private_message_id bigint NOT NULL DEFAULT 0,
message_box_id integer NOT NULL DEFAULT 0,
pts integer NOT NULL DEFAULT 0,
sent_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (broadcast_id, user_id),
CHECK (
(status = 'sent' AND private_message_id > 0 AND message_box_id > 0 AND pts > 0 AND sent_at IS NOT NULL)
OR
(status <> 'sent' AND private_message_id = 0 AND message_box_id = 0 AND pts = 0 AND sent_at IS NULL)
),
CHECK (
(status = 'processing' AND lease_token <> '' AND lease_until IS NOT NULL)
OR
(status <> 'processing' AND lease_token = '' AND lease_until IS NULL)
)
);
CREATE INDEX broadcasts_enumeration_idx ON broadcasts (id)
WHERE target_mode = 'all' AND NOT enumeration_done;
CREATE INDEX broadcast_recipients_pending_idx ON broadcast_recipients (next_attempt_at, id)
WHERE status = 'pending';
CREATE INDEX broadcast_recipients_processing_idx ON broadcast_recipients (lease_until, id)
WHERE status = 'processing';
CREATE INDEX broadcast_recipients_broadcast_idx ON broadcast_recipients (broadcast_id, id);

View file

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

View file

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

View file

@ -0,0 +1,3 @@
ALTER TABLE broadcasts
DROP CONSTRAINT IF EXISTS broadcasts_entities_array_check,
DROP COLUMN IF EXISTS entities;

View file

@ -0,0 +1,6 @@
ALTER TABLE broadcasts
ADD COLUMN entities jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE broadcasts
ADD CONSTRAINT broadcasts_entities_array_check
CHECK (jsonb_typeof(entities) = 'array');

View file

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

View file

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

View file

@ -0,0 +1,12 @@
DROP TRIGGER IF EXISTS channel_messages_suggested_post_lifecycle_wakeup ON public.channel_messages;
DROP FUNCTION IF EXISTS public.telesrv_wake_suggested_post_lifecycle_on_delete();
DROP TABLE IF EXISTS public.suggested_post_lifecycle_wakeups;
DROP INDEX IF EXISTS public.suggested_post_approvals_published_message_idx;
DROP INDEX IF EXISTS public.suggested_post_approvals_retry_idx;
ALTER TABLE public.suggested_post_approvals
DROP CONSTRAINT IF EXISTS suggested_post_approvals_retry_shape_check,
DROP COLUMN IF EXISTS last_lifecycle_error,
DROP COLUMN IF EXISTS next_attempt_at,
DROP COLUMN IF EXISTS lifecycle_attempts;

View file

@ -0,0 +1,94 @@
-- Isolate poisoned suggested-post lifecycle rows from the global due queue.
-- A failed aggregate remains durable and retryable, but receives a bounded
-- backoff so it cannot monopolize every one-second dispatcher pass.
ALTER TABLE public.suggested_post_approvals
ADD COLUMN lifecycle_attempts integer NOT NULL DEFAULT 0,
ADD COLUMN next_attempt_at integer NOT NULL DEFAULT 0,
ADD COLUMN last_lifecycle_error text NOT NULL DEFAULT '';
ALTER TABLE public.suggested_post_approvals
ADD CONSTRAINT suggested_post_approvals_retry_shape_check CHECK (
lifecycle_attempts BETWEEN 0 AND 1000000 AND
next_attempt_at >= 0 AND
octet_length(last_lifecycle_error) <= 4096
) NOT VALID;
ALTER TABLE public.suggested_post_approvals
VALIDATE CONSTRAINT suggested_post_approvals_retry_shape_check;
-- next_attempt_at is the queue's next eligible time, not merely a retry flag.
-- Healthy active rows point at their publish/settlement deadline; only an
-- explicit message-deletion wakeup or an already-due row is <= worker now.
UPDATE public.suggested_post_approvals
SET next_attempt_at = CASE state
WHEN 'scheduled' THEN schedule_date
WHEN 'published' THEN settlement_due
ELSE 0
END
WHERE state IN ('scheduled','published');
CREATE INDEX suggested_post_approvals_retry_idx
ON public.suggested_post_approvals(next_attempt_at,monoforum_id,suggestion_message_id)
WHERE state IN ('scheduled','published');
CREATE INDEX suggested_post_approvals_published_message_idx
ON public.suggested_post_approvals(parent_channel_id,published_message_id)
INCLUDE (monoforum_id,suggestion_message_id,next_attempt_at)
WHERE state='published';
-- A separate wakeup table avoids a message-row -> approval-row trigger lock
-- that would invert the worker's approval-row -> message-row order. It only
-- contains active suggested-post aggregates and is drained by atomic claim.
CREATE TABLE public.suggested_post_lifecycle_wakeups (
monoforum_id bigint NOT NULL,
suggestion_message_id integer NOT NULL,
created_at integer NOT NULL,
CONSTRAINT suggested_post_lifecycle_wakeups_pkey
PRIMARY KEY (monoforum_id,suggestion_message_id),
CONSTRAINT suggested_post_lifecycle_wakeups_shape_check
CHECK (monoforum_id>0 AND suggestion_message_id>0 AND created_at>=0)
);
CREATE INDEX suggested_post_lifecycle_wakeups_due_idx
ON public.suggested_post_lifecycle_wakeups(created_at,monoforum_id,suggestion_message_id);
-- Preserve deletions that predate this migration without making the recurring
-- worker rescan global channel tombstone history.
INSERT INTO public.suggested_post_lifecycle_wakeups(monoforum_id,suggestion_message_id,created_at)
SELECT a.monoforum_id,a.suggestion_message_id,0
FROM public.suggested_post_approvals a
WHERE (a.state='scheduled' AND EXISTS (
SELECT 1 FROM public.channel_messages m
WHERE m.channel_id=a.monoforum_id AND m.id=a.suggestion_message_id AND m.deleted))
OR (a.state='published' AND EXISTS (
SELECT 1 FROM public.channel_messages m
WHERE m.channel_id=a.parent_channel_id AND m.id=a.published_message_id AND m.deleted))
ON CONFLICT DO NOTHING;
-- Deletion is a durable lifecycle wakeup. Drive the lookup from the exact
-- message being tombstoned and only insert a small queue fact; never rescan
-- global channel tombstones from the one-second worker.
CREATE OR REPLACE FUNCTION public.telesrv_wake_suggested_post_lifecycle_on_delete()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO public.suggested_post_lifecycle_wakeups(monoforum_id,suggestion_message_id,created_at)
SELECT monoforum_id,suggestion_message_id,EXTRACT(EPOCH FROM clock_timestamp())::integer
FROM public.suggested_post_approvals
WHERE state='scheduled' AND monoforum_id=NEW.channel_id AND suggestion_message_id=NEW.id
ON CONFLICT DO NOTHING;
INSERT INTO public.suggested_post_lifecycle_wakeups(monoforum_id,suggestion_message_id,created_at)
SELECT monoforum_id,suggestion_message_id,EXTRACT(EPOCH FROM clock_timestamp())::integer
FROM public.suggested_post_approvals
WHERE state='published' AND parent_channel_id=NEW.channel_id AND published_message_id=NEW.id
ON CONFLICT DO NOTHING;
RETURN NEW;
END
$$;
CREATE TRIGGER channel_messages_suggested_post_lifecycle_wakeup
AFTER UPDATE OF deleted ON public.channel_messages
FOR EACH ROW
WHEN (NEW.deleted AND NOT OLD.deleted)
EXECUTE FUNCTION public.telesrv_wake_suggested_post_lifecycle_on_delete();

View file

@ -0,0 +1,5 @@
DROP INDEX IF EXISTS public.sticker_sets_system_key_idx;
CREATE INDEX sticker_sets_system_key_idx
ON public.sticker_sets USING btree (system_key)
WHERE system_key <> ''::text;

View file

@ -0,0 +1,25 @@
-- system_key is the constructor-routing identity for global sticker sets. More
-- than one row makes messages.getStickerSet/inputStickerSet* nondeterministic
-- and can alternate clients between different catalogs after a restart.
-- StatusPack is now imported from the official export. Preserve the historical
-- synthesized set as an addressable short-name catalog, but only remove its
-- routing key when another real default-status set already owns that key.
UPDATE public.sticker_sets AS synthesized
SET system_key = '', updated_at = now()
WHERE synthesized.id = 7777000000000001
AND synthesized.system_key = 'emoji_default_statuses'
AND EXISTS (
SELECT 1
FROM public.sticker_sets AS real_set
WHERE real_set.system_key = synthesized.system_key
AND real_set.id <> synthesized.id
);
DROP INDEX IF EXISTS public.sticker_sets_system_key_idx;
-- Fail closed on any other duplicate instead of making the read path choose an
-- arbitrary row. Empty keys are ordinary/non-system sets and are not unique.
CREATE UNIQUE INDEX sticker_sets_system_key_idx
ON public.sticker_sets USING btree (system_key)
WHERE system_key <> ''::text;

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS welcome_messages;
DROP TABLE IF EXISTS welcome_message_peers;

View file

@ -0,0 +1,31 @@
CREATE TABLE welcome_message_peers (
channel_id bigint PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE,
next_id integer NOT NULL DEFAULT 1,
revision bigint NOT NULL DEFAULT 1,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT welcome_message_peers_shape CHECK (
channel_id > 0 AND next_id > 0 AND next_id <= 2147483647 AND revision > 0
)
);
CREATE TABLE welcome_messages (
channel_id bigint NOT NULL REFERENCES welcome_message_peers(channel_id) ON DELETE CASCADE,
id integer NOT NULL,
creator_user_id bigint NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
date integer NOT NULL,
edit_date integer NOT NULL DEFAULT 0,
random_id bigint NOT NULL,
content jsonb NOT NULL,
create_fingerprint bytea NOT NULL,
version bigint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (channel_id, id),
UNIQUE (channel_id, creator_user_id, random_id),
CONSTRAINT welcome_messages_shape CHECK (
channel_id > 0 AND id > 0 AND creator_user_id > 0 AND date > 0 AND
edit_date >= 0 AND (edit_date = 0 OR edit_date >= date) AND random_id <> 0 AND
jsonb_typeof(content) = 'object' AND pg_column_size(content) <= 4194304 AND
octet_length(create_fingerprint) = 32 AND version > 0
)
);

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS welcome_message_deliveries;
DROP SEQUENCE IF EXISTS welcome_message_join_event_id_seq;

View file

@ -0,0 +1,37 @@
CREATE SEQUENCE welcome_message_join_event_id_seq AS bigint;
CREATE TABLE welcome_message_deliveries (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
join_event_id bigint NOT NULL,
channel_id bigint NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
target_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
template_id integer NOT NULL,
ephemeral_id integer GENERATED ALWAYS AS (((id - 1) % 2147483646 + 1)::integer) STORED,
joined_at integer NOT NULL,
content jsonb NOT NULL,
attempt_count integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
lease_owner text,
lease_expires_at timestamptz,
delivered_at timestamptz,
last_error text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL DEFAULT (now() + interval '24 hours'),
UNIQUE (join_event_id, template_id),
UNIQUE (target_user_id, ephemeral_id),
CONSTRAINT welcome_message_deliveries_shape CHECK (
join_event_id > 0 AND channel_id > 0 AND target_user_id > 0 AND
template_id > 0 AND ephemeral_id > 0 AND joined_at > 0 AND
jsonb_typeof(content) = 'object' AND pg_column_size(content) <= 4194304 AND
attempt_count >= 0 AND char_length(lease_owner) <= 128 AND
char_length(last_error) <= 1024 AND expires_at > created_at AND
expires_at <= created_at + interval '24 hours 1 second'
)
);
CREATE INDEX welcome_message_deliveries_due_idx
ON welcome_message_deliveries (next_attempt_at, id)
WHERE delivered_at IS NULL;
CREATE INDEX welcome_message_deliveries_expiry_idx
ON welcome_message_deliveries (expires_at, id);

View file

@ -0,0 +1 @@
DROP INDEX IF EXISTS welcome_message_deliveries_target_idx;

View file

@ -0,0 +1,2 @@
CREATE INDEX welcome_message_deliveries_target_idx
ON welcome_message_deliveries (channel_id, target_user_id, id);

View file

@ -0,0 +1,68 @@
DROP TRIGGER dispatch_outbox_insert_user_head ON dispatch_outbox;
DROP FUNCTION dispatch_outbox_insert_user_heads();
CREATE OR REPLACE FUNCTION dispatch_outbox_maintain_user_head()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
removed_head bigint;
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO dispatch_outbox_user_heads (
target_user_id, head_id, head_pts, status, next_attempt_at, updated_at
) VALUES (
NEW.target_user_id, NEW.id, NEW.pts, NEW.status, NEW.next_attempt_at, NEW.updated_at
)
ON CONFLICT (target_user_id) DO UPDATE
SET head_id = EXCLUDED.head_id,
head_pts = EXCLUDED.head_pts,
status = EXCLUDED.status,
next_attempt_at = EXCLUDED.next_attempt_at,
updated_at = EXCLUDED.updated_at
WHERE (EXCLUDED.head_pts, EXCLUDED.head_id) <
(dispatch_outbox_user_heads.head_pts, dispatch_outbox_user_heads.head_id);
RETURN NULL;
ELSIF TG_OP = 'UPDATE' THEN
UPDATE dispatch_outbox_user_heads
SET status = NEW.status,
next_attempt_at = NEW.next_attempt_at,
updated_at = NEW.updated_at
WHERE target_user_id = NEW.target_user_id
AND head_id = NEW.id;
RETURN NULL;
END IF;
DELETE FROM dispatch_outbox_user_heads
WHERE target_user_id = OLD.target_user_id
AND head_id = OLD.id
RETURNING head_id INTO removed_head;
IF removed_head IS NOT NULL THEN
INSERT INTO dispatch_outbox_user_heads (
target_user_id, head_id, head_pts, status, next_attempt_at, updated_at
)
SELECT target_user_id, id, pts, status, next_attempt_at, updated_at
FROM dispatch_outbox
WHERE target_user_id = OLD.target_user_id
ORDER BY pts ASC, id ASC
LIMIT 1
ON CONFLICT (target_user_id) DO UPDATE
SET head_id = EXCLUDED.head_id,
head_pts = EXCLUDED.head_pts,
status = EXCLUDED.status,
next_attempt_at = EXCLUDED.next_attempt_at,
updated_at = EXCLUDED.updated_at
WHERE (EXCLUDED.head_pts, EXCLUDED.head_id) <
(dispatch_outbox_user_heads.head_pts, dispatch_outbox_user_heads.head_id);
END IF;
RETURN NULL;
END;
$$;
CREATE TRIGGER dispatch_outbox_insert_user_head
AFTER INSERT ON dispatch_outbox
FOR EACH ROW
EXECUTE FUNCTION dispatch_outbox_maintain_user_head();
DROP FUNCTION dispatch_outbox_lane_advisory_key(bigint);

View file

@ -0,0 +1,118 @@
-- Producers hold a shared per-lane fence until commit. A consumer that may
-- remove the last head takes the exclusive form in a preceding statement;
-- after that fence is acquired, its DELETE sees every earlier append or every
-- later append observes the missing marker and installs a new one.
CREATE FUNCTION dispatch_outbox_lane_advisory_key(target_user_id bigint)
RETURNS bigint
LANGUAGE sql
IMMUTABLE
STRICT
PARALLEL SAFE
AS $$
SELECT hashtextextended('telesrv:dispatch-outbox-lane:' || target_user_id::text, 0)
$$;
DROP TRIGGER dispatch_outbox_insert_user_head ON dispatch_outbox;
CREATE FUNCTION dispatch_outbox_insert_user_heads()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM pg_advisory_xact_lock_shared(
dispatch_outbox_lane_advisory_key(streams.target_user_id)
)
FROM (
SELECT DISTINCT target_user_id
FROM new_rows
ORDER BY target_user_id
) AS streams;
-- The anti-join avoids touching a committed mutable head. ON CONFLICT is
-- retained only for concurrent producers racing to create an empty lane.
WITH candidates AS MATERIALIZED (
SELECT DISTINCT ON (target_user_id)
target_user_id, id AS head_id, pts AS head_pts,
status, next_attempt_at, updated_at
FROM new_rows
ORDER BY target_user_id, pts, id
)
INSERT INTO dispatch_outbox_user_heads (
target_user_id, head_id, head_pts, status, next_attempt_at, updated_at
)
SELECT
c.target_user_id, c.head_id, c.head_pts,
c.status, c.next_attempt_at, c.updated_at
FROM candidates c
WHERE NOT EXISTS (
SELECT 1
FROM dispatch_outbox_user_heads existing
WHERE existing.target_user_id = c.target_user_id
)
ON CONFLICT (target_user_id) DO NOTHING;
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION dispatch_outbox_maintain_user_head()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
removed_head bigint;
BEGIN
IF TG_OP = 'UPDATE' THEN
UPDATE dispatch_outbox_user_heads
SET status = NEW.status,
next_attempt_at = NEW.next_attempt_at,
updated_at = NEW.updated_at
WHERE target_user_id = NEW.target_user_id
AND head_id = NEW.id;
RETURN NULL;
END IF;
DELETE FROM dispatch_outbox_user_heads
WHERE target_user_id = OLD.target_user_id
AND head_id = OLD.id
RETURNING head_id INTO removed_head;
IF removed_head IS NOT NULL THEN
INSERT INTO dispatch_outbox_user_heads (
target_user_id, head_id, head_pts, status, next_attempt_at, updated_at
)
SELECT target_user_id, id, pts, status, next_attempt_at, updated_at
FROM dispatch_outbox
WHERE target_user_id = OLD.target_user_id
ORDER BY pts ASC, id ASC
LIMIT 1
ON CONFLICT (target_user_id) DO UPDATE
SET head_id = EXCLUDED.head_id,
head_pts = EXCLUDED.head_pts,
status = EXCLUDED.status,
next_attempt_at = EXCLUDED.next_attempt_at,
updated_at = EXCLUDED.updated_at
WHERE (EXCLUDED.head_pts, EXCLUDED.head_id) <
(dispatch_outbox_user_heads.head_pts, dispatch_outbox_user_heads.head_id);
END IF;
RETURN NULL;
END;
$$;
CREATE TRIGGER dispatch_outbox_insert_user_head
AFTER INSERT ON dispatch_outbox
REFERENCING NEW TABLE AS new_rows
FOR EACH STATEMENT
EXECUTE FUNCTION dispatch_outbox_insert_user_heads();
-- Repair any marker that may be absent when upgrading from an interrupted or
-- older deployment. Startup migration runs before the dispatcher starts.
INSERT INTO dispatch_outbox_user_heads (
target_user_id, head_id, head_pts, status, next_attempt_at, updated_at
)
SELECT DISTINCT ON (d.target_user_id)
d.target_user_id, d.id, d.pts, d.status, d.next_attempt_at, d.updated_at
FROM dispatch_outbox d
LEFT JOIN dispatch_outbox_user_heads h USING (target_user_id)
WHERE h.target_user_id IS NULL
ORDER BY d.target_user_id, d.pts, d.id
ON CONFLICT (target_user_id) DO NOTHING;

View file

@ -0,0 +1,11 @@
DROP TRIGGER IF EXISTS channel_message_reactions_dialog_top_projection_changed ON channel_message_reactions;
DROP FUNCTION IF EXISTS telesrv_notify_channel_top_reactions_read_model();
DROP TRIGGER IF EXISTS channel_messages_dialog_top_projection_changed ON channel_messages;
DROP FUNCTION IF EXISTS telesrv_notify_channel_top_message_read_model();
DROP TRIGGER IF EXISTS private_message_reactions_dialog_top_projection_changed ON private_message_reactions;
DROP FUNCTION IF EXISTS telesrv_notify_private_top_reactions_read_model();
DROP TRIGGER IF EXISTS message_boxes_dialog_top_projection_changed ON message_boxes;
DROP FUNCTION IF EXISTS telesrv_notify_private_top_message_read_model();

View file

@ -0,0 +1,135 @@
-- Complete the dialog_light/channel_base dependency closure for cached dialog
-- top-message projections. These triggers only bump read-model versions; they
-- do not allocate PTS or mutate message/update facts.
CREATE OR REPLACE FUNCTION telesrv_notify_private_top_message_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF EXISTS (
SELECT 1
FROM dialogs d
WHERE d.user_id = NEW.owner_user_id
AND d.peer_type = NEW.peer_type
AND d.peer_id = NEW.peer_id
AND d.top_message_id = NEW.box_id
) THEN
PERFORM telesrv_bump_dialog_light(NEW.owner_user_id, NEW.peer_type, NEW.peer_id);
END IF;
RETURN NULL;
END;
$$;
CREATE TRIGGER message_boxes_dialog_top_projection_changed
AFTER UPDATE OF
message_date, body, entities, deleted, edit_date, silent, noforwards,
reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
reply_to_story_id, quote_text, quote_entities, quote_offset,
fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date,
fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id,
media, media_unread, reaction_unread, ttl_period, expires_at, pinned,
saved_peer_type, saved_peer_id, reply_markup, via_bot_id, rich_message,
grouped_id, effect, hide_edited
ON message_boxes
FOR EACH ROW
EXECUTE FUNCTION telesrv_notify_private_top_message_read_model();
CREATE OR REPLACE FUNCTION telesrv_notify_private_top_reactions_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
changed_sender_id BIGINT;
changed_message_id BIGINT;
BEGIN
IF TG_OP = 'DELETE' THEN
changed_sender_id := OLD.message_sender_id;
changed_message_id := OLD.private_message_id;
ELSE
changed_sender_id := NEW.message_sender_id;
changed_message_id := NEW.private_message_id;
END IF;
PERFORM telesrv_bump_dialog_light(b.owner_user_id, b.peer_type, b.peer_id)
FROM message_boxes b
JOIN dialogs d
ON d.user_id = b.owner_user_id
AND d.peer_type = b.peer_type
AND d.peer_id = b.peer_id
AND d.top_message_id = b.box_id
WHERE b.message_sender_id = changed_sender_id
AND b.private_message_id = changed_message_id
AND NOT b.deleted;
RETURN NULL;
END;
$$;
CREATE TRIGGER private_message_reactions_dialog_top_projection_changed
AFTER INSERT OR DELETE OR UPDATE
ON private_message_reactions
FOR EACH ROW
EXECUTE FUNCTION telesrv_notify_private_top_reactions_read_model();
CREATE OR REPLACE FUNCTION telesrv_notify_channel_top_message_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF EXISTS (
SELECT 1
FROM channels c
WHERE c.id = NEW.channel_id
AND c.top_message_id = NEW.id
) THEN
PERFORM telesrv_bump_read_model_version('channel_base', 0, 'channel', NEW.channel_id);
END IF;
RETURN NULL;
END;
$$;
CREATE TRIGGER channel_messages_dialog_top_projection_changed
AFTER UPDATE OF
sender_user_id, from_peer_type, from_peer_id, send_as_peer_type, send_as_peer_id,
message_date, edit_date, post, silent, noforwards, body, entities, reply_to,
reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, fwd_from,
discussion_channel_id, discussion_message_id, action, deleted, views_count, media,
ttl_period, expires_at, post_author, pinned, via_bot_id, reply_markup,
from_boosts_applied, rich_message
ON channel_messages
FOR EACH ROW
EXECUTE FUNCTION telesrv_notify_channel_top_message_read_model();
CREATE OR REPLACE FUNCTION telesrv_notify_channel_top_reactions_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
changed_channel_id BIGINT;
changed_message_id INTEGER;
BEGIN
IF TG_OP = 'DELETE' THEN
changed_channel_id := OLD.channel_id;
changed_message_id := OLD.message_id;
ELSE
changed_channel_id := NEW.channel_id;
changed_message_id := NEW.message_id;
END IF;
IF EXISTS (
SELECT 1
FROM channels c
WHERE c.id = changed_channel_id
AND c.top_message_id = changed_message_id
) THEN
PERFORM telesrv_bump_read_model_version('channel_base', 0, 'channel', changed_channel_id);
END IF;
RETURN NULL;
END;
$$;
CREATE TRIGGER channel_message_reactions_dialog_top_projection_changed
AFTER INSERT OR DELETE OR UPDATE
ON channel_message_reactions
FOR EACH ROW
EXECUTE FUNCTION telesrv_notify_channel_top_reactions_read_model();

View file

@ -0,0 +1,7 @@
DROP TRIGGER IF EXISTS communities_bump_catalog_read_model ON public.communities;
DROP FUNCTION IF EXISTS public.telesrv_bump_community_catalog_read_model();
DELETE FROM public.read_model_versions
WHERE model = 'community_catalog'
AND owner_user_id = 0
AND peer_type = 'community'
AND peer_id = 0;

View file

@ -0,0 +1,34 @@
-- Global Community-catalog presence token. messages.getDialogs needs collapsed
-- Communities only when at least one live Community exists; without this
-- durable invalidation token an empty deployment would repeat the expensive
-- owner-membership query on every dialogs page.
CREATE OR REPLACE FUNCTION public.telesrv_bump_community_catalog_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM public.telesrv_bump_read_model_version(
'community_catalog',
0,
'community',
0
);
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS communities_bump_catalog_read_model ON public.communities;
CREATE TRIGGER communities_bump_catalog_read_model
AFTER INSERT OR UPDATE OR DELETE ON public.communities
FOR EACH ROW EXECUTE FUNCTION public.telesrv_bump_community_catalog_read_model();
SELECT public.telesrv_bump_read_model_version(
'community_catalog',
0,
'community',
0
);

View file

@ -0,0 +1,7 @@
DROP FUNCTION IF EXISTS public.telesrv_advance_auth_session_layer(
bigint,
bigint,
integer,
bigint,
timestamptz
);

View file

@ -0,0 +1,245 @@
-- Advance one explicit invokeWithLayer observation without paying one client
-- round trip for every lock, comparison and projection update. The caller
-- still owns the surrounding transaction/savepoint so identity_changed can
-- roll the complete attempt back and retry with a fresh READ COMMITTED view.
CREATE OR REPLACE FUNCTION public.telesrv_advance_auth_session_layer(
p_raw_auth_key_id bigint,
p_session_id bigint,
p_layer integer,
p_msg_id bigint,
p_expires_at timestamptz
)
RETURNS TABLE (
advance_status text,
current_layer integer,
current_msg_id bigint,
current_observation_id bigint,
current_expires_at timestamptz,
shared_default boolean,
applied boolean
)
LANGUAGE plpgsql
AS $$
DECLARE
v_now timestamptz := now();
v_hint_expiry integer;
v_hint_perm_id bigint;
v_hint_bound boolean := false;
v_hint_has_identity boolean := false;
v_hint_identity_id bigint;
v_raw_expiry integer;
v_actual_perm_id bigint;
v_actual_bound boolean := false;
v_actual_has_identity boolean := false;
v_actual_identity_id bigint;
v_perm_expiry integer;
v_current_found boolean := false;
v_current_layer integer := 0;
v_current_msg_id bigint := 0;
v_current_observation_id bigint := 0;
v_current_expires_at timestamptz := p_expires_at;
v_shared_default boolean := false;
v_observation_id bigint;
v_key_ids bigint[];
v_updated integer;
BEGIN
IF p_layer <= 0
OR p_msg_id <= 0
OR p_msg_id % 4 <> 0
OR (p_msg_id & 4294967295) = 0
OR p_expires_at IS NULL
OR NOT v_now < p_expires_at
OR p_expires_at - interval '301 seconds' > v_now + interval '30 seconds'
THEN
RETURN QUERY SELECT
'evidence_invalid'::text, 0, 0::bigint, 0::bigint,
COALESCE(p_expires_at, v_now), false, false;
RETURN;
END IF;
-- Lock-free identity hint. It is deliberately revalidated after the raw
-- row lock; a newly committed first bind returns identity_changed rather
-- than taking a permanent identity lock in raw->identity order.
SELECT key.expires_at, binding.perm_auth_key_id
INTO v_hint_expiry, v_hint_perm_id
FROM public.auth_keys AS key
LEFT JOIN public.temp_auth_key_bindings AS binding
ON binding.temp_auth_key_id = key.auth_key_id
WHERE key.auth_key_id = p_raw_auth_key_id;
IF NOT FOUND THEN
RETURN QUERY SELECT
'auth_key_not_found'::text, 0, 0::bigint, 0::bigint,
p_expires_at, false, false;
RETURN;
END IF;
v_hint_bound := v_hint_perm_id IS NOT NULL;
v_hint_has_identity := v_hint_bound OR v_hint_expiry = 0;
IF v_hint_has_identity THEN
v_hint_identity_id := CASE
WHEN v_hint_bound THEN v_hint_perm_id
ELSE p_raw_auth_key_id
END;
PERFORM pg_advisory_xact_lock(
1096111176::integer,
hashint8(v_hint_identity_id)::integer
);
END IF;
SELECT key.expires_at
INTO v_raw_expiry
FROM public.auth_keys AS key
WHERE key.auth_key_id = p_raw_auth_key_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN QUERY SELECT
'auth_key_not_found'::text, 0, 0::bigint, 0::bigint,
p_expires_at, false, false;
RETURN;
END IF;
v_actual_perm_id := NULL;
SELECT binding.perm_auth_key_id
INTO v_actual_perm_id
FROM public.temp_auth_key_bindings AS binding
WHERE binding.temp_auth_key_id = p_raw_auth_key_id;
v_actual_bound := FOUND;
IF NOT v_actual_bound THEN
v_actual_perm_id := p_raw_auth_key_id;
END IF;
v_actual_has_identity := v_actual_bound OR v_raw_expiry = 0;
v_actual_identity_id := v_actual_perm_id;
IF v_actual_has_identity <> v_hint_has_identity
OR (v_actual_has_identity AND v_actual_identity_id <> v_hint_identity_id)
OR v_actual_bound <> v_hint_bound
OR v_raw_expiry <> v_hint_expiry
THEN
RETURN QUERY SELECT
'identity_changed'::text, 0, 0::bigint, 0::bigint,
p_expires_at, false, false;
RETURN;
END IF;
IF v_actual_bound THEN
SELECT key.expires_at
INTO v_perm_expiry
FROM public.auth_keys AS key
WHERE key.auth_key_id = v_actual_perm_id
FOR UPDATE;
IF NOT FOUND OR v_raw_expiry <= 0 OR v_perm_expiry <> 0 THEN
RETURN QUERY SELECT
'binding_invalid'::text, 0, 0::bigint, 0::bigint,
p_expires_at, false, false;
RETURN;
END IF;
END IF;
SELECT evidence.layer,
evidence.msg_id,
evidence.observation_id,
evidence.expires_at
INTO v_current_layer,
v_current_msg_id,
v_current_observation_id,
v_current_expires_at
FROM public.auth_key_session_layers AS evidence
WHERE evidence.raw_auth_key_id = p_raw_auth_key_id
AND evidence.session_id = p_session_id
FOR UPDATE;
v_current_found := FOUND;
IF v_current_found AND v_now < v_current_expires_at THEN
IF p_msg_id < v_current_msg_id THEN
SELECT key.layer = v_current_layer
AND key.layer_observation_id = v_current_observation_id
INTO v_shared_default
FROM public.auth_keys AS key
WHERE key.auth_key_id = v_actual_perm_id;
RETURN QUERY SELECT
'ok'::text, v_current_layer, v_current_msg_id,
v_current_observation_id, v_current_expires_at,
v_shared_default, false;
RETURN;
END IF;
IF p_msg_id = v_current_msg_id THEN
IF p_layer <> v_current_layer THEN
RETURN QUERY SELECT
'conflict'::text, v_current_layer, v_current_msg_id,
v_current_observation_id, v_current_expires_at,
false, false;
RETURN;
END IF;
SELECT key.layer = v_current_layer
AND key.layer_observation_id = v_current_observation_id
INTO v_shared_default
FROM public.auth_keys AS key
WHERE key.auth_key_id = v_actual_perm_id;
RETURN QUERY SELECT
'ok'::text, v_current_layer, v_current_msg_id,
v_current_observation_id, v_current_expires_at,
v_shared_default, false;
RETURN;
END IF;
END IF;
v_observation_id := nextval('public.auth_key_layer_observation_seq');
INSERT INTO public.auth_key_session_layers (
raw_auth_key_id,
session_id,
layer,
msg_id,
observation_id,
expires_at
) VALUES (
p_raw_auth_key_id,
p_session_id,
p_layer,
p_msg_id,
v_observation_id,
p_expires_at
)
ON CONFLICT (raw_auth_key_id, session_id) DO UPDATE SET
layer = EXCLUDED.layer,
msg_id = EXCLUDED.msg_id,
observation_id = EXCLUDED.observation_id,
expires_at = EXCLUDED.expires_at
RETURNING layer, msg_id, observation_id, expires_at
INTO v_current_layer,
v_current_msg_id,
v_current_observation_id,
v_current_expires_at;
v_key_ids := ARRAY[p_raw_auth_key_id];
IF v_actual_perm_id <> p_raw_auth_key_id THEN
v_key_ids := array_append(v_key_ids, v_actual_perm_id);
END IF;
UPDATE public.auth_keys AS key
SET layer = p_layer,
layer_observation_id = v_observation_id
WHERE key.auth_key_id = ANY(v_key_ids)
AND key.layer_observation_id < v_observation_id;
GET DIAGNOSTICS v_updated = ROW_COUNT;
IF v_updated <> cardinality(v_key_ids) THEN
RAISE EXCEPTION
'publish auth session Layer defaults updated % of % locked keys',
v_updated,
cardinality(v_key_ids)
USING ERRCODE = '23000';
END IF;
UPDATE public.authorizations AS authz
SET layer = p_layer
WHERE authz.auth_key_id = ANY(v_key_ids);
RETURN QUERY SELECT
'ok'::text, v_current_layer, v_current_msg_id,
v_current_observation_id, v_current_expires_at,
true, true;
END;
$$;
COMMENT ON FUNCTION public.telesrv_advance_auth_session_layer(
bigint, bigint, integer, bigint, timestamptz
) IS 'Atomically advances durable invokeWithLayer evidence while preserving auth identity lock order';

View file

@ -0,0 +1,7 @@
DROP TRIGGER IF EXISTS custom_verifications_peer_identity_changed ON public.custom_verifications;
DROP TRIGGER IF EXISTS peer_usernames_peer_identity_changed ON public.peer_usernames;
DROP FUNCTION IF EXISTS public.telesrv_notify_peer_identity_row();
DROP FUNCTION IF EXISTS public.telesrv_bump_peer_identity(text, bigint);
DELETE FROM public.read_model_versions
WHERE model = 'peer_identity';

View file

@ -0,0 +1,68 @@
-- Viewer-independent peer decorations are projected on every peer-bearing RPC
-- response. Keep one durable version token for username registry vectors and
-- third-party bot-verification marks so positive and negative L1 entries can be
-- reused without one PostgreSQL query per response page.
CREATE OR REPLACE FUNCTION public.telesrv_bump_peer_identity(
p_peer_type text,
p_peer_id bigint
) RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
IF COALESCE(p_peer_id, 0) = 0 OR p_peer_type NOT IN ('user', 'channel') THEN
RETURN;
END IF;
PERFORM public.telesrv_bump_read_model_version(
'peer_identity', 0, p_peer_type, p_peer_id
);
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_peer_identity_row()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM public.telesrv_bump_peer_identity(OLD.peer_type, OLD.peer_id);
RETURN OLD;
END IF;
IF TG_OP = 'UPDATE'
AND (OLD.peer_type, OLD.peer_id) IS DISTINCT FROM (NEW.peer_type, NEW.peer_id) THEN
PERFORM public.telesrv_bump_peer_identity(OLD.peer_type, OLD.peer_id);
END IF;
PERFORM public.telesrv_bump_peer_identity(NEW.peer_type, NEW.peer_id);
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS peer_usernames_peer_identity_changed ON public.peer_usernames;
CREATE TRIGGER peer_usernames_peer_identity_changed
AFTER INSERT OR UPDATE OR DELETE ON public.peer_usernames
FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_peer_identity_row();
DROP TRIGGER IF EXISTS custom_verifications_peer_identity_changed ON public.custom_verifications;
CREATE TRIGGER custom_verifications_peer_identity_changed
AFTER INSERT OR UPDATE OR DELETE ON public.custom_verifications
FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_peer_identity_row();
-- Seed every durable peer, including peers with no registry row/mark. This is
-- what makes an empty result a versioned negative fact rather than a TTL guess.
INSERT INTO public.read_model_versions (
model, owner_user_id, peer_type, peer_id, version, hash, updated_at
)
SELECT 'peer_identity', 0, 'user', u.id, 1,
public.telesrv_random_read_model_hash(), now()
FROM public.users u
ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO NOTHING;
INSERT INTO public.read_model_versions (
model, owner_user_id, peer_type, peer_id, version, hash, updated_at
)
SELECT 'peer_identity', 0, 'channel', c.id, 1,
public.telesrv_random_read_model_hash(), now()
FROM public.channels c
ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO NOTHING;

View file

@ -0,0 +1,43 @@
DROP TRIGGER IF EXISTS users_story_projection_versions_changed ON public.users;
DROP TRIGGER IF EXISTS channels_story_projection_version_changed ON public.channels;
DROP TRIGGER IF EXISTS telesrv_stories_story_peer_read_model ON public.stories;
DROP TRIGGER IF EXISTS telesrv_story_hidden_peers_story_peer_read_model ON public.story_hidden_peers;
DROP FUNCTION IF EXISTS public.telesrv_maintain_user_story_projection_versions();
DROP FUNCTION IF EXISTS public.telesrv_maintain_channel_story_projection_version();
DROP FUNCTION IF EXISTS public.telesrv_notify_story_row_read_models();
DROP FUNCTION IF EXISTS public.telesrv_notify_story_hidden_row_read_models();
DROP FUNCTION IF EXISTS public.telesrv_bump_story_hidden_list(bigint);
DROP FUNCTION IF EXISTS public.telesrv_bump_story_peer(text, bigint);
CREATE OR REPLACE FUNCTION public.telesrv_notify_story_peer_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
o_type text;
o_id bigint;
BEGIN
IF TG_OP = 'DELETE' THEN
o_type := OLD.owner_peer_type;
o_id := OLD.owner_peer_id;
ELSE
o_type := NEW.owner_peer_type;
o_id := NEW.owner_peer_id;
END IF;
PERFORM public.telesrv_bump_read_model_version('story_peer', 0, o_type, o_id);
RETURN NULL;
END;
$$;
CREATE TRIGGER telesrv_stories_story_peer_read_model
AFTER INSERT OR UPDATE OR DELETE ON public.stories
FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_story_peer_read_model();
CREATE TRIGGER telesrv_story_hidden_peers_story_peer_read_model
AFTER INSERT OR UPDATE OR DELETE ON public.story_hidden_peers
FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_story_peer_read_model();
DELETE FROM public.read_model_versions WHERE model = 'story_hidden_list';
-- story_peer rows seeded for peers without stories are harmless under the
-- previous contract and are retained so rollback cannot erase real versions.

View file

@ -0,0 +1,161 @@
-- Split story peer projection into a shared active-story gate and one sparse
-- hidden-peer snapshot per viewer. Both positive and negative facts receive a
-- durable token; active expiry is still enforced by expire_date at read time.
CREATE OR REPLACE FUNCTION public.telesrv_bump_story_peer(
p_peer_type text,
p_peer_id bigint
) RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
IF COALESCE(p_peer_type, '') NOT IN ('user', 'channel')
OR COALESCE(p_peer_id, 0) = 0 THEN
RETURN;
END IF;
PERFORM public.telesrv_bump_read_model_version(
'story_peer', 0, p_peer_type, p_peer_id
);
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_bump_story_hidden_list(
p_viewer_user_id bigint
) RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
IF COALESCE(p_viewer_user_id, 0) = 0 THEN
RETURN;
END IF;
PERFORM public.telesrv_bump_read_model_version(
'story_hidden_list', p_viewer_user_id, 'user', p_viewer_user_id
);
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_story_row_read_models()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM public.telesrv_bump_story_peer(OLD.owner_peer_type, OLD.owner_peer_id);
RETURN OLD;
END IF;
IF TG_OP = 'UPDATE'
AND (OLD.owner_peer_type, OLD.owner_peer_id)
IS DISTINCT FROM (NEW.owner_peer_type, NEW.owner_peer_id) THEN
PERFORM public.telesrv_bump_story_peer(OLD.owner_peer_type, OLD.owner_peer_id);
END IF;
PERFORM public.telesrv_bump_story_peer(NEW.owner_peer_type, NEW.owner_peer_id);
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_story_hidden_row_read_models()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM public.telesrv_bump_story_peer(OLD.owner_peer_type, OLD.owner_peer_id);
PERFORM public.telesrv_bump_story_hidden_list(OLD.viewer_user_id);
RETURN OLD;
END IF;
IF TG_OP = 'UPDATE' THEN
IF (OLD.owner_peer_type, OLD.owner_peer_id)
IS DISTINCT FROM (NEW.owner_peer_type, NEW.owner_peer_id) THEN
PERFORM public.telesrv_bump_story_peer(OLD.owner_peer_type, OLD.owner_peer_id);
END IF;
IF OLD.viewer_user_id IS DISTINCT FROM NEW.viewer_user_id THEN
PERFORM public.telesrv_bump_story_hidden_list(OLD.viewer_user_id);
END IF;
END IF;
PERFORM public.telesrv_bump_story_peer(NEW.owner_peer_type, NEW.owner_peer_id);
PERFORM public.telesrv_bump_story_hidden_list(NEW.viewer_user_id);
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS telesrv_stories_story_peer_read_model ON public.stories;
CREATE TRIGGER telesrv_stories_story_peer_read_model
AFTER INSERT OR UPDATE OR DELETE ON public.stories
FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_story_row_read_models();
DROP TRIGGER IF EXISTS telesrv_story_hidden_peers_story_peer_read_model ON public.story_hidden_peers;
CREATE TRIGGER telesrv_story_hidden_peers_story_peer_read_model
AFTER INSERT OR UPDATE OR DELETE ON public.story_hidden_peers
FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_story_hidden_row_read_models();
CREATE OR REPLACE FUNCTION public.telesrv_maintain_user_story_projection_versions()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM public.telesrv_bump_story_peer('user', OLD.id);
PERFORM public.telesrv_bump_story_hidden_list(OLD.id);
DELETE FROM public.read_model_versions
WHERE (model = 'story_peer' AND owner_user_id = 0
AND peer_type = 'user' AND peer_id = OLD.id)
OR (model = 'story_hidden_list' AND owner_user_id = OLD.id
AND peer_type = 'user' AND peer_id = OLD.id);
RETURN OLD;
END IF;
PERFORM public.telesrv_bump_story_peer('user', NEW.id);
PERFORM public.telesrv_bump_story_hidden_list(NEW.id);
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS users_story_projection_versions_changed ON public.users;
CREATE TRIGGER users_story_projection_versions_changed
AFTER INSERT OR DELETE ON public.users
FOR EACH ROW EXECUTE FUNCTION public.telesrv_maintain_user_story_projection_versions();
CREATE OR REPLACE FUNCTION public.telesrv_maintain_channel_story_projection_version()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM public.telesrv_bump_story_peer('channel', OLD.id);
DELETE FROM public.read_model_versions
WHERE model = 'story_peer' AND owner_user_id = 0
AND peer_type = 'channel' AND peer_id = OLD.id;
RETURN OLD;
END IF;
PERFORM public.telesrv_bump_story_peer('channel', NEW.id);
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS channels_story_projection_version_changed ON public.channels;
CREATE TRIGGER channels_story_projection_version_changed
AFTER INSERT OR DELETE ON public.channels
FOR EACH ROW EXECUTE FUNCTION public.telesrv_maintain_channel_story_projection_version();
INSERT INTO public.read_model_versions (
model, owner_user_id, peer_type, peer_id, version, hash, updated_at
)
SELECT 'story_peer', 0, 'user', u.id, 1,
public.telesrv_random_read_model_hash(), now()
FROM public.users u
ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO NOTHING;
INSERT INTO public.read_model_versions (
model, owner_user_id, peer_type, peer_id, version, hash, updated_at
)
SELECT 'story_peer', 0, 'channel', c.id, 1,
public.telesrv_random_read_model_hash(), now()
FROM public.channels c
ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO NOTHING;
INSERT INTO public.read_model_versions (
model, owner_user_id, peer_type, peer_id, version, hash, updated_at
)
SELECT 'story_hidden_list', u.id, 'user', u.id, 1,
public.telesrv_random_read_model_hash(), now()
FROM public.users u
ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO NOTHING;

View file

@ -0,0 +1,95 @@
DROP TRIGGER IF EXISTS users_dialog_owner_read_model ON public.users;
DROP FUNCTION IF EXISTS public.telesrv_seed_dialog_owner_read_model();
DELETE FROM public.read_model_versions WHERE model = 'dialog_owner';
CREATE OR REPLACE FUNCTION public.telesrv_notify_dialog_light_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
owner_id bigint;
peer_type text;
peer_id bigint;
BEGIN
IF TG_OP = 'DELETE' THEN
owner_id := OLD.user_id;
peer_type := OLD.peer_type;
peer_id := OLD.peer_id;
ELSE
owner_id := NEW.user_id;
peer_type := NEW.peer_type;
peer_id := NEW.peer_id;
END IF;
PERFORM public.telesrv_bump_dialog_light(owner_id, peer_type, peer_id);
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_channel_dialog_light_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
owner_id bigint;
channel_id bigint;
BEGIN
IF TG_OP = 'DELETE' THEN
owner_id := OLD.user_id;
channel_id := OLD.channel_id;
ELSE
owner_id := NEW.user_id;
channel_id := NEW.channel_id;
END IF;
PERFORM public.telesrv_bump_dialog_light(owner_id, 'channel', channel_id);
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_channel_member_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
channel_id bigint;
user_id bigint;
BEGIN
IF TG_OP = 'DELETE' THEN
channel_id := OLD.channel_id;
user_id := OLD.user_id;
ELSE
channel_id := NEW.channel_id;
user_id := NEW.user_id;
END IF;
PERFORM public.telesrv_bump_read_model_version('channel_member', user_id, 'channel', channel_id);
PERFORM public.telesrv_bump_dialog_light(user_id, 'channel', channel_id);
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_bump_dialog_light(
p_owner_user_id bigint,
p_peer_type text,
p_peer_id bigint
)
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
IF COALESCE(p_owner_user_id, 0) = 0
OR COALESCE(p_peer_id, 0) = 0
OR COALESCE(p_peer_type, '') = ''
THEN
RETURN;
END IF;
PERFORM public.telesrv_bump_read_model_version(
'dialog_light', p_owner_user_id, p_peer_type, p_peer_id
);
END;
$$;
DROP FUNCTION IF EXISTS public.telesrv_bump_dialog_owner(bigint);

View file

@ -0,0 +1,161 @@
-- Owner collection generation for the Redis-backed messages.getDialogs header
-- snapshot. Shared channel mutations remain channel_base-only; membership and
-- owner-local dialog mutations advance this O(1) owner token.
CREATE OR REPLACE FUNCTION public.telesrv_bump_dialog_owner(p_owner_user_id bigint)
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
IF COALESCE(p_owner_user_id, 0) = 0 THEN
RETURN;
END IF;
PERFORM public.telesrv_bump_read_model_version(
'dialog_owner', p_owner_user_id, 'user', p_owner_user_id
);
END;
$$;
-- dialog_owner is the owner-wide aggregate of every exact dialog_light
-- dependency. Extending the common helper closes private top-message,
-- reaction, contact/profile and future exact-dialog invalidation paths without
-- copying owner bumps into every trigger.
CREATE OR REPLACE FUNCTION public.telesrv_bump_dialog_light(
p_owner_user_id bigint,
p_peer_type text,
p_peer_id bigint
)
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
IF COALESCE(p_owner_user_id, 0) = 0
OR COALESCE(p_peer_id, 0) = 0
OR COALESCE(p_peer_type, '') = ''
THEN
RETURN;
END IF;
PERFORM public.telesrv_bump_read_model_version(
'dialog_light', p_owner_user_id, p_peer_type, p_peer_id
);
PERFORM public.telesrv_bump_dialog_owner(p_owner_user_id);
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_seed_dialog_owner_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM public.telesrv_bump_dialog_owner(NEW.id);
RETURN NULL;
END;
$$;
CREATE TRIGGER users_dialog_owner_read_model
AFTER INSERT ON public.users
FOR EACH ROW EXECUTE FUNCTION public.telesrv_seed_dialog_owner_read_model();
CREATE OR REPLACE FUNCTION public.telesrv_notify_dialog_light_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
old_owner_id bigint;
old_peer_type text;
old_peer_id bigint;
new_owner_id bigint;
new_peer_type text;
new_peer_id bigint;
BEGIN
IF TG_OP <> 'INSERT' THEN
old_owner_id := OLD.user_id;
old_peer_type := OLD.peer_type;
old_peer_id := OLD.peer_id;
PERFORM public.telesrv_bump_dialog_light(old_owner_id, old_peer_type, old_peer_id);
END IF;
IF TG_OP <> 'DELETE' THEN
new_owner_id := NEW.user_id;
new_peer_type := NEW.peer_type;
new_peer_id := NEW.peer_id;
IF TG_OP = 'INSERT'
OR (new_owner_id, new_peer_type, new_peer_id)
IS DISTINCT FROM (old_owner_id, old_peer_type, old_peer_id)
THEN
PERFORM public.telesrv_bump_dialog_light(new_owner_id, new_peer_type, new_peer_id);
END IF;
END IF;
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_channel_dialog_light_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
old_owner_id bigint;
old_channel_id bigint;
new_owner_id bigint;
new_channel_id bigint;
BEGIN
IF TG_OP <> 'INSERT' THEN
old_owner_id := OLD.user_id;
old_channel_id := OLD.channel_id;
PERFORM public.telesrv_bump_dialog_light(old_owner_id, 'channel', old_channel_id);
END IF;
IF TG_OP <> 'DELETE' THEN
new_owner_id := NEW.user_id;
new_channel_id := NEW.channel_id;
IF TG_OP = 'INSERT'
OR (new_owner_id, new_channel_id) IS DISTINCT FROM (old_owner_id, old_channel_id)
THEN
PERFORM public.telesrv_bump_dialog_light(new_owner_id, 'channel', new_channel_id);
END IF;
END IF;
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_channel_member_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
old_channel_id bigint;
old_user_id bigint;
new_channel_id bigint;
new_user_id bigint;
BEGIN
IF TG_OP <> 'INSERT' THEN
old_channel_id := OLD.channel_id;
old_user_id := OLD.user_id;
PERFORM public.telesrv_bump_read_model_version('channel_member', old_user_id, 'channel', old_channel_id);
PERFORM public.telesrv_bump_dialog_light(old_user_id, 'channel', old_channel_id);
END IF;
IF TG_OP <> 'DELETE' THEN
new_channel_id := NEW.channel_id;
new_user_id := NEW.user_id;
IF TG_OP = 'INSERT'
OR (new_user_id, new_channel_id) IS DISTINCT FROM (old_user_id, old_channel_id)
THEN
PERFORM public.telesrv_bump_read_model_version('channel_member', new_user_id, 'channel', new_channel_id);
PERFORM public.telesrv_bump_dialog_light(new_user_id, 'channel', new_channel_id);
END IF;
END IF;
RETURN NULL;
END;
$$;
INSERT INTO public.read_model_versions (
model, owner_user_id, peer_type, peer_id, version, hash, updated_at
)
SELECT 'dialog_owner', u.id, 'user', u.id, 1,
public.telesrv_random_read_model_hash(), now()
FROM public.users AS u
ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO NOTHING;

View file

@ -0,0 +1,109 @@
DROP FUNCTION IF EXISTS public.telesrv_bump_channel_membership_read_models(bigint, bigint[]);
CREATE OR REPLACE FUNCTION public.telesrv_notify_channel_member_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
old_channel_id bigint;
old_user_id bigint;
new_channel_id bigint;
new_user_id bigint;
BEGIN
IF TG_OP <> 'INSERT' THEN
old_channel_id := OLD.channel_id;
old_user_id := OLD.user_id;
PERFORM public.telesrv_bump_read_model_version('channel_member', old_user_id, 'channel', old_channel_id);
PERFORM public.telesrv_bump_dialog_light(old_user_id, 'channel', old_channel_id);
END IF;
IF TG_OP <> 'DELETE' THEN
new_channel_id := NEW.channel_id;
new_user_id := NEW.user_id;
IF TG_OP = 'INSERT' OR (new_user_id, new_channel_id) IS DISTINCT FROM (old_user_id, old_channel_id) THEN
PERFORM public.telesrv_bump_read_model_version('channel_member', new_user_id, 'channel', new_channel_id);
PERFORM public.telesrv_bump_dialog_light(new_user_id, 'channel', new_channel_id);
END IF;
END IF;
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_channel_participants_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
old_channel_id bigint;
new_channel_id bigint;
BEGIN
IF TG_OP = 'DELETE' THEN old_channel_id := OLD.channel_id;
ELSIF TG_OP = 'INSERT' THEN new_channel_id := NEW.channel_id;
ELSE old_channel_id := OLD.channel_id; new_channel_id := NEW.channel_id;
END IF;
IF old_channel_id IS NOT NULL THEN
PERFORM public.telesrv_bump_read_model_version('channel_participants', 0, 'channel', old_channel_id);
END IF;
IF new_channel_id IS NOT NULL AND new_channel_id IS DISTINCT FROM old_channel_id THEN
PERFORM public.telesrv_bump_read_model_version('channel_participants', 0, 'channel', new_channel_id);
END IF;
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_channel_dialog_light_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
old_owner_id bigint;
old_channel_id bigint;
new_owner_id bigint;
new_channel_id bigint;
BEGIN
IF TG_OP <> 'INSERT' THEN
old_owner_id := OLD.user_id; old_channel_id := OLD.channel_id;
PERFORM public.telesrv_bump_dialog_light(old_owner_id, 'channel', old_channel_id);
END IF;
IF TG_OP <> 'DELETE' THEN
new_owner_id := NEW.user_id; new_channel_id := NEW.channel_id;
IF TG_OP = 'INSERT' OR (new_owner_id, new_channel_id) IS DISTINCT FROM (old_owner_id, old_channel_id) THEN
PERFORM public.telesrv_bump_dialog_light(new_owner_id, 'channel', new_channel_id);
END IF;
END IF;
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_channel_active_memberships_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
old_owner bigint;
new_owner bigint;
changed boolean;
BEGIN
IF TG_OP = 'DELETE' THEN
old_owner := OLD.user_id;
IF old_owner <> 0 THEN PERFORM public.telesrv_bump_read_model_version('channel_active_memberships', old_owner, 'user', old_owner); END IF;
RETURN NULL;
END IF;
new_owner := NEW.user_id;
IF TG_OP = 'INSERT' THEN
IF new_owner <> 0 THEN PERFORM public.telesrv_bump_read_model_version('channel_active_memberships', new_owner, 'user', new_owner); END IF;
RETURN NULL;
END IF;
old_owner := OLD.user_id;
changed := OLD.user_id IS DISTINCT FROM NEW.user_id OR OLD.channel_id IS DISTINCT FROM NEW.channel_id OR
OLD.status IS DISTINCT FROM NEW.status OR OLD.deleted IS DISTINCT FROM NEW.deleted;
IF changed THEN
IF old_owner <> 0 THEN PERFORM public.telesrv_bump_read_model_version('channel_active_memberships', old_owner, 'user', old_owner); END IF;
IF new_owner <> 0 AND new_owner IS DISTINCT FROM old_owner THEN
PERFORM public.telesrv_bump_read_model_version('channel_active_memberships', new_owner, 'user', new_owner);
END IF;
END IF;
RETURN NULL;
END;
$$;
DROP FUNCTION IF EXISTS public.telesrv_membership_batch_active();

View file

@ -0,0 +1,221 @@
-- Batch membership mutations are one durable state transition. Row triggers
-- remain authoritative for ordinary statements, while the explicitly scoped
-- batch path suppresses their per-row write amplification and advances every
-- distinct dependency once before the transaction may commit.
CREATE OR REPLACE FUNCTION public.telesrv_membership_batch_active()
RETURNS boolean
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE(current_setting('telesrv.membership_batch_mode', true), '') = 'on'
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_channel_member_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
old_channel_id bigint;
old_user_id bigint;
new_channel_id bigint;
new_user_id bigint;
BEGIN
IF public.telesrv_membership_batch_active() THEN
RETURN NULL;
END IF;
IF TG_OP <> 'INSERT' THEN
old_channel_id := OLD.channel_id;
old_user_id := OLD.user_id;
PERFORM public.telesrv_bump_read_model_version('channel_member', old_user_id, 'channel', old_channel_id);
PERFORM public.telesrv_bump_dialog_light(old_user_id, 'channel', old_channel_id);
END IF;
IF TG_OP <> 'DELETE' THEN
new_channel_id := NEW.channel_id;
new_user_id := NEW.user_id;
IF TG_OP = 'INSERT'
OR (new_user_id, new_channel_id) IS DISTINCT FROM (old_user_id, old_channel_id)
THEN
PERFORM public.telesrv_bump_read_model_version('channel_member', new_user_id, 'channel', new_channel_id);
PERFORM public.telesrv_bump_dialog_light(new_user_id, 'channel', new_channel_id);
END IF;
END IF;
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_channel_participants_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
old_channel_id bigint;
new_channel_id bigint;
BEGIN
IF public.telesrv_membership_batch_active() THEN
RETURN NULL;
END IF;
IF TG_OP = 'DELETE' THEN
old_channel_id := OLD.channel_id;
ELSIF TG_OP = 'INSERT' THEN
new_channel_id := NEW.channel_id;
ELSE
old_channel_id := OLD.channel_id;
new_channel_id := NEW.channel_id;
END IF;
IF old_channel_id IS NOT NULL THEN
PERFORM public.telesrv_bump_read_model_version('channel_participants', 0, 'channel', old_channel_id);
END IF;
IF new_channel_id IS NOT NULL AND new_channel_id IS DISTINCT FROM old_channel_id THEN
PERFORM public.telesrv_bump_read_model_version('channel_participants', 0, 'channel', new_channel_id);
END IF;
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_channel_dialog_light_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
old_owner_id bigint;
old_channel_id bigint;
new_owner_id bigint;
new_channel_id bigint;
BEGIN
IF public.telesrv_membership_batch_active() THEN
RETURN NULL;
END IF;
IF TG_OP <> 'INSERT' THEN
old_owner_id := OLD.user_id;
old_channel_id := OLD.channel_id;
PERFORM public.telesrv_bump_dialog_light(old_owner_id, 'channel', old_channel_id);
END IF;
IF TG_OP <> 'DELETE' THEN
new_owner_id := NEW.user_id;
new_channel_id := NEW.channel_id;
IF TG_OP = 'INSERT'
OR (new_owner_id, new_channel_id) IS DISTINCT FROM (old_owner_id, old_channel_id)
THEN
PERFORM public.telesrv_bump_dialog_light(new_owner_id, 'channel', new_channel_id);
END IF;
END IF;
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_channel_active_memberships_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
old_owner bigint;
new_owner bigint;
changed boolean;
BEGIN
IF public.telesrv_membership_batch_active() THEN
RETURN NULL;
END IF;
IF TG_OP = 'DELETE' THEN
old_owner := OLD.user_id;
IF old_owner <> 0 THEN
PERFORM public.telesrv_bump_read_model_version('channel_active_memberships', old_owner, 'user', old_owner);
END IF;
RETURN NULL;
END IF;
new_owner := NEW.user_id;
IF TG_OP = 'INSERT' THEN
IF new_owner <> 0 THEN
PERFORM public.telesrv_bump_read_model_version('channel_active_memberships', new_owner, 'user', new_owner);
END IF;
RETURN NULL;
END IF;
old_owner := OLD.user_id;
changed :=
OLD.user_id IS DISTINCT FROM NEW.user_id OR
OLD.channel_id IS DISTINCT FROM NEW.channel_id OR
OLD.status IS DISTINCT FROM NEW.status OR
OLD.deleted IS DISTINCT FROM NEW.deleted;
IF changed THEN
IF old_owner <> 0 THEN
PERFORM public.telesrv_bump_read_model_version('channel_active_memberships', old_owner, 'user', old_owner);
END IF;
IF new_owner <> 0 AND new_owner IS DISTINCT FROM old_owner THEN
PERFORM public.telesrv_bump_read_model_version('channel_active_memberships', new_owner, 'user', new_owner);
END IF;
END IF;
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_bump_channel_membership_read_models(
p_channel_id bigint,
p_user_ids bigint[]
)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
changed record;
BEGIN
IF COALESCE(p_channel_id, 0) <= 0 OR COALESCE(cardinality(p_user_ids), 0) = 0 THEN
RETURN;
END IF;
FOR changed IN
WITH requested AS MATERIALIZED (
SELECT DISTINCT user_id
FROM unnest(p_user_ids) AS input(user_id)
WHERE user_id > 0
), keys AS MATERIALIZED (
SELECT 10 AS priority, 'channel_participants'::text AS model, 0::bigint AS owner_user_id,
'channel'::text AS peer_type, p_channel_id AS peer_id
UNION
SELECT 20, 'channel_member', user_id, 'channel', p_channel_id FROM requested
UNION
SELECT 30, 'dialog_light', user_id, 'channel', p_channel_id FROM requested
UNION
SELECT 40, 'dialog_owner', user_id, 'user', user_id FROM requested
UNION
SELECT 50, 'channel_active_memberships', user_id, 'user', user_id FROM requested
), bumped AS (
INSERT INTO public.read_model_versions (
model, owner_user_id, peer_type, peer_id, version, hash, updated_at
)
SELECT model, owner_user_id, peer_type, peer_id, 1,
public.telesrv_random_read_model_hash(), now()
FROM keys
ORDER BY priority, owner_user_id, peer_type, peer_id
ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO UPDATE SET
version = public.read_model_versions.version + 1,
hash = public.telesrv_random_read_model_hash(),
updated_at = EXCLUDED.updated_at
RETURNING model, owner_user_id, peer_type, peer_id, version, hash
)
SELECT model, owner_user_id, peer_type, peer_id, version, hash
FROM bumped
ORDER BY model, owner_user_id, peer_type, peer_id
LOOP
PERFORM pg_notify(
'telesrv_read_model_changed',
json_build_object(
'model', changed.model,
'owner_user_id', changed.owner_user_id,
'peer_type', changed.peer_type,
'peer_id', changed.peer_id,
'version', changed.version,
'hash', changed.hash
)::text
);
END LOOP;
END;
$$;

View file

@ -0,0 +1,62 @@
CREATE OR REPLACE FUNCTION public.telesrv_bump_channel_membership_read_models(
p_channel_id bigint,
p_user_ids bigint[]
)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
changed record;
BEGIN
IF COALESCE(p_channel_id, 0) <= 0 OR COALESCE(cardinality(p_user_ids), 0) = 0 THEN
RETURN;
END IF;
FOR changed IN
WITH requested AS MATERIALIZED (
SELECT DISTINCT user_id
FROM unnest(p_user_ids) AS input(user_id)
WHERE user_id > 0
), keys AS MATERIALIZED (
SELECT 'channel_participants'::text AS model, 0::bigint AS owner_user_id,
'channel'::text AS peer_type, p_channel_id AS peer_id
UNION
SELECT 'channel_member', user_id, 'channel', p_channel_id FROM requested
UNION
SELECT 'dialog_light', user_id, 'channel', p_channel_id FROM requested
UNION
SELECT 'dialog_owner', user_id, 'user', user_id FROM requested
UNION
SELECT 'channel_active_memberships', user_id, 'user', user_id FROM requested
), bumped AS (
INSERT INTO public.read_model_versions (
model, owner_user_id, peer_type, peer_id, version, hash, updated_at
)
SELECT model, owner_user_id, peer_type, peer_id, 1,
public.telesrv_random_read_model_hash(), now()
FROM keys
ORDER BY model, owner_user_id, peer_type, peer_id
ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO UPDATE SET
version = public.read_model_versions.version + 1,
hash = public.telesrv_random_read_model_hash(),
updated_at = EXCLUDED.updated_at
RETURNING model, owner_user_id, peer_type, peer_id, version, hash
)
SELECT model, owner_user_id, peer_type, peer_id, version, hash
FROM bumped
ORDER BY model, owner_user_id, peer_type, peer_id
LOOP
PERFORM pg_notify(
'telesrv_read_model_changed',
json_build_object(
'model', changed.model,
'owner_user_id', changed.owner_user_id,
'peer_type', changed.peer_type,
'peer_id', changed.peer_id,
'version', changed.version,
'hash', changed.hash
)::text
);
END LOOP;
END;
$$;

View file

@ -0,0 +1,68 @@
-- 20260901000017 initially ordered version keys lexically, placing
-- channel_active_memberships before the member/dialog keys. Ordinary single-row
-- membership writes acquire those keys in the opposite order, so a concurrent
-- createChannel and batch invite could deadlock. Use the canonical physical
-- mutation order: participants -> member -> dialog -> owner -> active index.
CREATE OR REPLACE FUNCTION public.telesrv_bump_channel_membership_read_models(
p_channel_id bigint,
p_user_ids bigint[]
)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
changed record;
BEGIN
IF COALESCE(p_channel_id, 0) <= 0 OR COALESCE(cardinality(p_user_ids), 0) = 0 THEN
RETURN;
END IF;
FOR changed IN
WITH requested AS MATERIALIZED (
SELECT DISTINCT user_id
FROM unnest(p_user_ids) AS input(user_id)
WHERE user_id > 0
), keys AS MATERIALIZED (
SELECT 10 AS priority, 'channel_participants'::text AS model, 0::bigint AS owner_user_id,
'channel'::text AS peer_type, p_channel_id AS peer_id
UNION
SELECT 20, 'channel_member', user_id, 'channel', p_channel_id FROM requested
UNION
SELECT 30, 'dialog_light', user_id, 'channel', p_channel_id FROM requested
UNION
SELECT 40, 'dialog_owner', user_id, 'user', user_id FROM requested
UNION
SELECT 50, 'channel_active_memberships', user_id, 'user', user_id FROM requested
), bumped AS (
INSERT INTO public.read_model_versions (
model, owner_user_id, peer_type, peer_id, version, hash, updated_at
)
SELECT model, owner_user_id, peer_type, peer_id, 1,
public.telesrv_random_read_model_hash(), now()
FROM keys
ORDER BY priority, owner_user_id, peer_type, peer_id
ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO UPDATE SET
version = public.read_model_versions.version + 1,
hash = public.telesrv_random_read_model_hash(),
updated_at = EXCLUDED.updated_at
RETURNING model, owner_user_id, peer_type, peer_id, version, hash
)
SELECT model, owner_user_id, peer_type, peer_id, version, hash
FROM bumped
ORDER BY model, owner_user_id, peer_type, peer_id
LOOP
PERFORM pg_notify(
'telesrv_read_model_changed',
json_build_object(
'model', changed.model,
'owner_user_id', changed.owner_user_id,
'peer_type', changed.peer_type,
'peer_id', changed.peer_id,
'version', changed.version,
'hash', changed.hash
)::text
);
END LOOP;
END;
$$;

View file

@ -0,0 +1,7 @@
DROP TRIGGER IF EXISTS channels_peer_identity_created ON public.channels;
DROP TRIGGER IF EXISTS users_peer_identity_created ON public.users;
DROP FUNCTION IF EXISTS public.telesrv_seed_peer_identity_on_insert();
-- Backfilled read_model_versions rows are intentionally retained. 20260901000014 defines
-- one token for every durable peer; deleting them would violate the older
-- migration's contract after rollback and make negative facts uncacheable.

View file

@ -0,0 +1,68 @@
-- 20260901000014 seeded only the peers that existed when that migration ran. Real account
-- provisioning and channel creation after deployment therefore had no durable
-- token, so their empty username/verification facts were deliberately
-- uncacheable. Maintain the token at the peer lifecycle boundary instead.
CREATE OR REPLACE FUNCTION public.telesrv_seed_peer_identity_on_insert()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_TABLE_NAME = 'users' THEN
PERFORM public.telesrv_bump_peer_identity('user', NEW.id);
ELSIF TG_TABLE_NAME = 'channels' THEN
PERFORM public.telesrv_bump_peer_identity('channel', NEW.id);
ELSE
RAISE EXCEPTION 'unsupported peer identity seed table: %', TG_TABLE_NAME;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS users_peer_identity_created ON public.users;
CREATE TRIGGER users_peer_identity_created
AFTER INSERT ON public.users
FOR EACH ROW EXECUTE FUNCTION public.telesrv_seed_peer_identity_on_insert();
DROP TRIGGER IF EXISTS channels_peer_identity_created ON public.channels;
CREATE TRIGGER channels_peer_identity_created
AFTER INSERT ON public.channels
FOR EACH ROW EXECUTE FUNCTION public.telesrv_seed_peer_identity_on_insert();
-- Use the same bump helper rather than a silent INSERT so already-running
-- instances that cached a missing/zero token receive exact-key invalidation.
DO $$
DECLARE
peer_row record;
BEGIN
FOR peer_row IN
SELECT u.id
FROM public.users u
WHERE NOT EXISTS (
SELECT 1 FROM public.read_model_versions v
WHERE v.model = 'peer_identity'
AND v.owner_user_id = 0
AND v.peer_type = 'user'
AND v.peer_id = u.id
)
ORDER BY u.id
LOOP
PERFORM public.telesrv_bump_peer_identity('user', peer_row.id);
END LOOP;
FOR peer_row IN
SELECT c.id
FROM public.channels c
WHERE NOT EXISTS (
SELECT 1 FROM public.read_model_versions v
WHERE v.model = 'peer_identity'
AND v.owner_user_id = 0
AND v.peer_type = 'channel'
AND v.peer_id = c.id
)
ORDER BY c.id
LOOP
PERFORM public.telesrv_bump_peer_identity('channel', peer_row.id);
END LOOP;
END;
$$;

View file

@ -0,0 +1,8 @@
DROP FUNCTION IF EXISTS public.telesrv_bind_temp_auth_key(
bigint,
bigint,
bigint,
bigint,
integer,
bytea
);

View file

@ -0,0 +1,154 @@
-- Collapse the identity-sensitive auth.bindTempAuthKey write boundary into one
-- database call. The caller still owns the surrounding transaction/savepoint;
-- this function preserves the global identity -> raw temp -> permanent row
-- lock order shared with selector advance, revocation and direct deletion.
CREATE OR REPLACE FUNCTION public.telesrv_bind_temp_auth_key(
p_temp_auth_key_id bigint,
p_perm_auth_key_id bigint,
p_nonce bigint,
p_temp_session_id bigint,
p_expires_at integer,
p_encrypted_message bytea
)
RETURNS TABLE (
bind_status text,
merged_layer integer,
merged_observation_id bigint
)
LANGUAGE plpgsql
AS $$
DECLARE
v_temp_expiry integer;
v_temp_layer integer;
v_temp_observation_id bigint;
v_current_perm_id bigint;
v_perm_expiry integer;
v_perm_layer integer;
v_perm_observation_id bigint;
v_merged_layer integer;
v_merged_observation_id bigint;
v_updated integer;
BEGIN
IF p_expires_at <= 0 OR p_temp_auth_key_id = p_perm_auth_key_id THEN
RETURN QUERY SELECT 'binding_invalid'::text, 0, 0::bigint;
RETURN;
END IF;
PERFORM pg_advisory_xact_lock(
1096111176::integer,
hashint8(p_perm_auth_key_id)::integer
);
SELECT key.expires_at, key.layer, key.layer_observation_id
INTO v_temp_expiry, v_temp_layer, v_temp_observation_id
FROM public.auth_keys AS key
WHERE key.auth_key_id = p_temp_auth_key_id
FOR UPDATE;
IF NOT FOUND OR v_temp_expiry <= 0 OR v_temp_expiry <> p_expires_at THEN
RETURN QUERY SELECT 'binding_invalid'::text, 0, 0::bigint;
RETURN;
END IF;
SELECT binding.perm_auth_key_id
INTO v_current_perm_id
FROM public.temp_auth_key_bindings AS binding
WHERE binding.temp_auth_key_id = p_temp_auth_key_id;
IF FOUND AND v_current_perm_id <> p_perm_auth_key_id THEN
RETURN QUERY SELECT 'already_bound'::text, 0, 0::bigint;
RETURN;
END IF;
SELECT key.expires_at, key.layer, key.layer_observation_id
INTO v_perm_expiry, v_perm_layer, v_perm_observation_id
FROM public.auth_keys AS key
WHERE key.auth_key_id = p_perm_auth_key_id
FOR UPDATE;
IF NOT FOUND OR v_perm_expiry <> 0 THEN
RETURN QUERY SELECT 'binding_invalid'::text, 0, 0::bigint;
RETURN;
END IF;
IF v_temp_layer < 0
OR v_perm_layer < 0
OR v_temp_observation_id < 0
OR v_perm_observation_id < 0
OR (v_temp_observation_id > 0 AND v_temp_layer = 0)
OR (v_perm_observation_id > 0 AND v_perm_layer = 0)
THEN
RETURN QUERY SELECT 'layer_invalid'::text, 0, 0::bigint;
RETURN;
END IF;
IF v_temp_observation_id > v_perm_observation_id THEN
v_merged_layer := v_temp_layer;
v_merged_observation_id := v_temp_observation_id;
ELSIF v_perm_observation_id > v_temp_observation_id THEN
v_merged_layer := v_perm_layer;
v_merged_observation_id := v_perm_observation_id;
ELSIF v_temp_observation_id > 0 AND v_temp_layer <> v_perm_layer THEN
RETURN QUERY SELECT 'layer_conflict'::text, 0, 0::bigint;
RETURN;
ELSIF v_temp_observation_id > 0 THEN
v_merged_layer := v_temp_layer;
v_merged_observation_id := v_temp_observation_id;
ELSE
v_merged_layer := v_perm_layer;
v_merged_observation_id := 0;
END IF;
INSERT INTO public.temp_auth_key_bindings (
temp_auth_key_id,
perm_auth_key_id,
nonce,
temp_session_id,
expires_at,
encrypted_message
) VALUES (
p_temp_auth_key_id,
p_perm_auth_key_id,
p_nonce,
p_temp_session_id,
p_expires_at,
p_encrypted_message
)
ON CONFLICT (temp_auth_key_id) DO UPDATE SET
nonce = EXCLUDED.nonce,
temp_session_id = EXCLUDED.temp_session_id,
expires_at = EXCLUDED.expires_at,
encrypted_message = EXCLUDED.encrypted_message,
created_at = now()
WHERE public.temp_auth_key_bindings.perm_auth_key_id = EXCLUDED.perm_auth_key_id;
GET DIAGNOSTICS v_updated = ROW_COUNT;
IF v_updated <> 1 THEN
RETURN QUERY SELECT 'binding_invalid'::text, 0, 0::bigint;
RETURN;
END IF;
UPDATE public.auth_keys AS key
SET layer = v_merged_layer,
layer_observation_id = v_merged_observation_id
WHERE key.auth_key_id = ANY(
ARRAY[p_temp_auth_key_id, p_perm_auth_key_id]::bigint[]
);
GET DIAGNOSTICS v_updated = ROW_COUNT;
IF v_updated <> 2 THEN
RAISE EXCEPTION
'merge bound auth key Layer defaults updated % of 2 locked keys',
v_updated
USING ERRCODE = '23000';
END IF;
UPDATE public.authorizations AS authz
SET layer = v_merged_layer
WHERE authz.auth_key_id = ANY(
ARRAY[p_temp_auth_key_id, p_perm_auth_key_id]::bigint[]
);
RETURN QUERY SELECT 'ok'::text, v_merged_layer, v_merged_observation_id;
END;
$$;
COMMENT ON FUNCTION public.telesrv_bind_temp_auth_key(
bigint, bigint, bigint, bigint, integer, bytea
) IS 'Atomically binds a temporary auth key and returns the committed Layer observation while preserving identity lock order';

View file

@ -0,0 +1 @@
DROP INDEX IF EXISTS public.bootstrap_update_jobs_pending_auth_idx;

View file

@ -0,0 +1,3 @@
CREATE INDEX IF NOT EXISTS bootstrap_update_jobs_pending_auth_idx
ON public.bootstrap_update_jobs (user_id, auth_key_id, id)
WHERE (status)::text = 'pending'::text;

View file

@ -0,0 +1,10 @@
DROP TRIGGER IF EXISTS channels_monoforum_active_membership_changed ON public.channels;
DROP FUNCTION IF EXISTS public.telesrv_notify_monoforum_channel_visibility_read_model();
DROP FUNCTION IF EXISTS public.telesrv_bump_monoforum_visibility_owners(bigint, bigint);
DROP TRIGGER IF EXISTS channel_members_monoforum_manager_visibility_changed ON public.channel_members;
DROP FUNCTION IF EXISTS public.telesrv_notify_monoforum_manager_visibility_read_model();
DROP TRIGGER IF EXISTS channel_messages_monoforum_active_membership_changed ON public.channel_messages;
DROP FUNCTION IF EXISTS public.telesrv_notify_monoforum_message_visibility_read_model();
DROP FUNCTION IF EXISTS public.telesrv_bump_channel_active_membership_owner(bigint);

View file

@ -0,0 +1,194 @@
-- Extend the owner-scoped channel_active_memberships generation to every
-- durable predicate used by ListActiveChannelIDsForUser. Ordinary memberships
-- are covered by user_channel_member_index; these triggers cover monoforum
-- subscriber message visibility, manager rights, and parent/mono enablement.
CREATE OR REPLACE FUNCTION public.telesrv_bump_channel_active_membership_owner(p_user_id bigint)
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
IF COALESCE(p_user_id, 0) <= 0 THEN
RETURN;
END IF;
PERFORM public.telesrv_bump_read_model_version(
'channel_active_memberships', p_user_id, 'user', p_user_id
);
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_monoforum_message_visibility_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
old_owner bigint := 0;
new_owner bigint := 0;
BEGIN
IF TG_OP <> 'INSERT'
AND OLD.saved_peer_type = 'user'
AND OLD.saved_peer_id > 0
AND NOT OLD.deleted
THEN
old_owner := OLD.saved_peer_id;
END IF;
IF TG_OP <> 'DELETE'
AND NEW.saved_peer_type = 'user'
AND NEW.saved_peer_id > 0
AND NOT NEW.deleted
THEN
new_owner := NEW.saved_peer_id;
END IF;
IF old_owner > 0 THEN
PERFORM public.telesrv_bump_channel_active_membership_owner(old_owner);
END IF;
IF new_owner > 0 AND new_owner IS DISTINCT FROM old_owner THEN
PERFORM public.telesrv_bump_channel_active_membership_owner(new_owner);
END IF;
RETURN NULL;
END;
$$;
CREATE TRIGGER channel_messages_monoforum_active_membership_changed
AFTER INSERT OR DELETE OR UPDATE OF channel_id, saved_peer_type, saved_peer_id, deleted
ON public.channel_messages
FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_monoforum_message_visibility_read_model();
CREATE OR REPLACE FUNCTION public.telesrv_notify_monoforum_manager_visibility_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.role IS NOT DISTINCT FROM NEW.role
AND OLD.admin_rights IS NOT DISTINCT FROM NEW.admin_rights
THEN
RETURN NULL;
END IF;
IF EXISTS (
SELECT 1
FROM public.channels AS parent
WHERE parent.id = NEW.channel_id
AND parent.linked_monoforum_id <> 0
AND parent.broadcast_messages_allowed
AND NOT parent.deleted
) THEN
PERFORM public.telesrv_bump_channel_active_membership_owner(NEW.user_id);
END IF;
RETURN NULL;
END;
$$;
CREATE TRIGGER channel_members_monoforum_manager_visibility_changed
AFTER UPDATE OF role, admin_rights ON public.channel_members
FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_monoforum_manager_visibility_read_model();
CREATE OR REPLACE FUNCTION public.telesrv_bump_monoforum_visibility_owners(
p_parent_id bigint,
p_monoforum_id bigint
)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
affected record;
BEGIN
IF COALESCE(p_parent_id, 0) <= 0 OR COALESCE(p_monoforum_id, 0) <= 0 THEN
RETURN;
END IF;
FOR affected IN
SELECT DISTINCT owner_id
FROM (
SELECT member.user_id AS owner_id
FROM public.user_channel_member_index AS member
WHERE member.channel_id = p_parent_id
AND member.status = 'active'
AND NOT member.deleted
AND member.role IN ('creator', 'admin')
UNION
SELECT message.saved_peer_id AS owner_id
FROM public.channel_messages AS message
WHERE message.channel_id = p_monoforum_id
AND message.saved_peer_type = 'user'
AND message.saved_peer_id > 0
AND NOT message.deleted
) AS owners
WHERE owner_id > 0
ORDER BY owner_id
LOOP
PERFORM public.telesrv_bump_channel_active_membership_owner(affected.owner_id);
END LOOP;
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_monoforum_channel_visibility_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
old_parent_id bigint := 0;
old_monoforum_id bigint := 0;
new_parent_id bigint := 0;
new_monoforum_id bigint := 0;
BEGIN
IF (OLD.broadcast_messages_allowed, OLD.linked_monoforum_id, OLD.deleted, OLD.monoforum)
IS NOT DISTINCT FROM
(NEW.broadcast_messages_allowed, NEW.linked_monoforum_id, NEW.deleted, NEW.monoforum)
THEN
RETURN NULL;
END IF;
IF OLD.linked_monoforum_id > 0 THEN
IF OLD.monoforum THEN
old_parent_id := OLD.linked_monoforum_id;
old_monoforum_id := OLD.id;
ELSE
old_parent_id := OLD.id;
old_monoforum_id := OLD.linked_monoforum_id;
END IF;
END IF;
IF NEW.linked_monoforum_id > 0 THEN
IF NEW.monoforum THEN
new_parent_id := NEW.linked_monoforum_id;
new_monoforum_id := NEW.id;
ELSE
new_parent_id := NEW.id;
new_monoforum_id := NEW.linked_monoforum_id;
END IF;
END IF;
IF old_parent_id > 0 THEN
PERFORM public.telesrv_bump_monoforum_visibility_owners(old_parent_id, old_monoforum_id);
END IF;
IF new_parent_id > 0
AND (new_parent_id, new_monoforum_id)
IS DISTINCT FROM (old_parent_id, old_monoforum_id)
THEN
PERFORM public.telesrv_bump_monoforum_visibility_owners(new_parent_id, new_monoforum_id);
END IF;
RETURN NULL;
END;
$$;
CREATE TRIGGER channels_monoforum_active_membership_changed
AFTER UPDATE OF broadcast_messages_allowed, linked_monoforum_id, deleted, monoforum
ON public.channels
FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_monoforum_channel_visibility_read_model();
-- Existing monoforum owners need a fresh generation before any shared page can
-- be populated under the expanded dependency contract.
DO $$
DECLARE
linked record;
BEGIN
FOR linked IN
SELECT parent.id AS parent_id, parent.linked_monoforum_id AS monoforum_id
FROM public.channels AS parent
WHERE parent.linked_monoforum_id > 0
LOOP
PERFORM public.telesrv_bump_monoforum_visibility_owners(
linked.parent_id, linked.monoforum_id
);
END LOOP;
END;
$$;

View file

@ -0,0 +1,9 @@
ALTER TABLE public.bot_verifier_settings
DROP CONSTRAINT bot_verifier_settings_default_description_check,
ADD CONSTRAINT bot_verifier_settings_default_description_check
CHECK (octet_length(default_description) <= 280);
ALTER TABLE public.custom_verification_requests
DROP CONSTRAINT custom_verification_requests_requested_description_check,
ADD CONSTRAINT custom_verification_requests_requested_description_check
CHECK (octet_length(requested_description) <= 280);

View file

@ -0,0 +1,9 @@
ALTER TABLE public.bot_verifier_settings
DROP CONSTRAINT bot_verifier_settings_default_description_check,
ADD CONSTRAINT bot_verifier_settings_default_description_check
CHECK (octet_length(default_description) <= 512);
ALTER TABLE public.custom_verification_requests
DROP CONSTRAINT custom_verification_requests_requested_description_check,
ADD CONSTRAINT custom_verification_requests_requested_description_check
CHECK (octet_length(requested_description) <= 512);

View file

@ -0,0 +1,11 @@
\set ON_ERROR_STOP on
-- main and v2 intentionally have independent migration histories. Keep them
-- in separate PostgreSQL databases even when they share the local container.
SELECT 'CREATE DATABASE telesrv_main OWNER telesrv'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'telesrv_main')
\gexec
SELECT 'CREATE DATABASE telesrv_v2 OWNER telesrv'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'telesrv_v2')
\gexec

View file

@ -0,0 +1,46 @@
{
"schema_version": 1,
"desktop": {
"win64": {
"stable": {
"build": 7000007,
"version": "7.0.7",
"file": "tx64upd7000007",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"size": 0,
"disabled": true
}
}
},
"apps": {
"android": {
"stable": {
"id": 2026080701,
"version": "12.9.1",
"url": "https://updates.example.test/apps/telesrv-12.9.1.apk",
"url_by_source": {
"com.android.vending": "https://play.google.com/store/apps/details?id=your.application.id"
},
"can_not_skip": false,
"notes": {
"en": "A new version is available.",
"ru": "Доступна новая версия приложения."
},
"disabled": true
}
},
"ios": {
"stable": {
"id": 2026080702,
"version": "12.9.1",
"url": "https://apps.apple.com/app/id0000000000",
"can_not_skip": false,
"notes": {
"en": "A new version is available in the App Store.",
"ru": "В App Store доступна новая версия приложения."
},
"disabled": true
}
}
}
}

403
docs/admin-panel-api.en.md Normal file
View file

@ -0,0 +1,403 @@
# Admin panel: all routes and API contracts
Documentation of every route in the built-in `telesrv` admin panel — the SPA
page addresses and the API routes behind them. The panel server lives in
`cmd/telesrv-admin`; routes are declared in `(*server).routes()`
(`server.go:50`).
The panel is a plain HTTP server: it serves the built frontend (`/`) and a
JSON API under the `/api/` prefix. Every API path requires a session (cookie),
and every mutating request additionally requires a CSRF token. Many routes also
check an operator permission (`permission`).
## How to use this document
- For each GET endpoint, the **request parameters** (query) and a short **response** shape are listed.
- For each POST endpoint under `/api/actions/*` and the verification decisions, the **request body** (JSON) is given. They all share common fields (see below) plus their own specific fields.
- `int64` in JSON may be sent either as a number or as a string (e.g. `"user_id": "123"` or `"user_id": 123`) — the server accepts both (`flexInt64`). `flexUnix` accepts Unix seconds or a date in `2006-01-02` / RFC3339 form.
- All mutating requests go to the Admin API. A command's response is either `{"status":..., "message":..., "command_id":...}` on success (HTTP 200) or `{"error":..., "code":...}` on error. An optimistic-locking conflict is `409`.
## Authentication and session
| Method | Path | Description |
| --- | --- | --- |
| POST | `/api/login` | Log in. Body: `{"secret": "..."}`. Validates the config secret, issues a signed session cookie and CSRF cookie. The only mutating route without a CSRF token (no session yet); only Origin is checked. |
| POST | `/api/logout` | Log out. Destroys the session and CSRF cookie. |
| GET | `/api/session` | Returns `{"actor": "...", "permissions": [...], "csrf_token": "..."}`. Called by the panel on startup. |
The session lives in a signed cookie (default TTL 12 hours); there is no
server-side session store. Every request except `GET/HEAD/OPTIONS` and
`/api/login` must present:
- the `telesrv_admin_csrf` cookie;
- the `X-CSRF-Token` header with the same value;
- an Origin matching the host (when Origin is present).
Violating any of the three — `403 Forbidden`.
## Permissions and their checks
The panel distinguishes plain authorization (`requireAuthAPI`) from a
permission check (`requirePermission`). Permission names match the strings in
`TELESRV_ADMIN_UI_PERMISSIONS`:
| Permission | What it grants |
| --- | --- |
| `*` | All permissions (wildcard). |
| `premium.manage` | Managing the Premium plan catalog, granting Premium. |
| `bots.token.read` | Exporting a bot token (`/api/actions/export-bot-token`). |
| `verification.review` | Reading and deciding the official verification queue. |
| `verification.revoke` | Stripping the official badge (in addition to `verification.review`). |
| `botverification.review` | Reading and deciding the third-party (bot) verification queue. |
| `botverification.manage` | Appointing verifiers, editing the icon catalog, stripping a third-party mark. |
Official and third-party verification rights are intentionally independent. The
session's permission list is returned from `/api/session` so the UI can hide
unavailable sections.
## Interface pages (SPA)
SPA routes are declarative: any section name is served as `/` (index.html), and
the frontend decides what to render (`web/src/pages/Routes.tsx`).
| Path | Page |
| --- | --- |
| `/` | Dashboard (counters, storage stats, section links). |
| `/accounts` | Account list (with search). |
| `/accounts/{id}` | Account card: profile, actions. |
| `/channels` | Supergroup and channel list. |
| `/channels/{id}` | Channel card. |
| `/bots` | Bot list. |
| `/bots/{id}` | Bot card. |
| `/broadcasts` | Broadcasts. |
| `/monetization`, `/premium` | Stars and Premium: plans, grants. Requires `premium.manage`. |
| `/moderation` | Complaints and moderation: case list. |
| `/moderation/{id}` | Moderation case details. |
| `/emoji` | Emoji set catalog. |
| `/stickers` | Sticker pack catalog. |
| `/gif-catalog` | GIF catalog. |
| `/messages`, `/messages/private` | Private message audit. |
| `/messages/detail`, `/messages/private/detail` | Private message detail (`?owner_user_id=&msg_id=`). |
| `/messages/groups` | Group/channel message audit. |
| `/messages/groups/detail` | Group message detail (`?channel_id=&msg_id=`). |
| `/gifts` | Star gifts: catalog, collectibles, auctions. |
| `/give-gifts` | Gift granting. |
| `/collectible-usernames` | Collectible usernames. |
| `/collectible-usernames/{id}` | Collectible username card. |
| `/collectible-phones` | Anonymous numbers. |
| `/account-ratings` | Account ratings. |
| `/account-ratings/{user_id}` | Account rating card. |
| `/storage` | Object storage: stats. |
| `/verification` | Official verification: application queue. Requires `verification.review`. |
| `/verification/{id}` | Official verification application details. Requires `verification.review`. |
| `/bot-verification` | Third-party verification: verifiers, icons, marks, queue. Requires `botverification.review`. |
| `/bot-verification/{id}` | Third-party verification request details. Requires `botverification.review`. |
An unknown API path returns `404 {"error":"api route not found"}`; any unknown
frontend path is served as `/`.
## Response conventions
- Successful reads — `200` + JSON; files (avatars, animations, previews) — the file itself.
- Errors — JSON of the form `{"error": "...", "code": "..."}` with the corresponding HTTP status.
- `401` — missing/expired session; `403` — CSRF/Origin violation or missing permission (in `requirePermission` the body gets an added `permission` field); `409` — optimistic-locking conflict (moderation case, verification); `502` — Admin API unreachable.
- Errors from commands sent to the Admin API are returned as `{"status": ..., "message": ..., "error": ...}`.
## Common command fields (POST `/api/actions/*`)
Every mutating request carries in its body:
| Field | Type | Description |
| --- | --- | --- |
| `command_id` | string | Idempotency key for the command. Repeating the same `command_id` does not execute the action twice. If empty — generated by the server. |
| `reason` | string | Mandatory operation reason (audit). |
| `confirm` | bool | Operator confirmation (`true`). |
---
## API: dashboard and storage
| Method | Path | Description |
| --- | --- | --- |
| GET | `/api/dashboard` | Summary: `counts`, `storage`, and optionally `host`. |
| GET | `/api/storage/stats` | Object storage statistics. |
## API: accounts
| Method | Path | Parameters / response |
| --- | --- | --- |
| GET | `/api/accounts` | Params: `q` (search), `before_id` (int64), `before_active_us` (int64, microseconds), `limit` (int). Response: `query`, `limit`, `rows`, `has_more`, `next_before_id`, `next_before_active_us`, `listing`. |
| GET | `/api/accounts/{id}` | Account card: profile, flags, statistics. |
| GET | `/api/accounts/{id}/avatar` | Account avatar (file). |
| GET | `/api/account-ratings` | Params: `q`, `min_level` (int), `user_id` (int64), `before_id` (int64), `limit` (int). Response: `rows`, `has_more`, `next_before_id`. |
| GET | `/api/account-ratings/{user_id}` | Response: `rating`, `events`. |
## API: channels and supergroups
| Method | Path | Parameters / response |
| --- | --- | --- |
| GET | `/api/channels` | Params: `q`, `before_id` (int64), `before_updated_us` (int64, microseconds), `limit` (int). Response: `query`, `limit`, `rows`, `has_more`, `next_before_id`, `next_before_updated_us`, `listing`. |
| GET | `/api/channels/{id}` | Channel card. |
| GET | `/api/channels/{id}/avatar` | Channel avatar (file). |
## API: bots and broadcasts
| Method | Path | Parameters / response |
| --- | --- | --- |
| GET | `/api/bots` | Params: `q`, `before_id` (int64), `limit` (int). Response: `query`, `limit`, `rows`, `has_more`, `next_before_id`, `listing`. |
| GET | `/api/bots/{id}` | Bot card. |
| GET | `/api/broadcasts` | Params: `before_id` (int64), `limit` (int). Response: `limit`, `rows`, `has_more`, `next_before_id`. |
## API: media catalogs
| Method | Path | Parameters / response |
| --- | --- | --- |
| GET | `/api/emoji` | Params: `q`, `before_id` (int64), `limit` (int). Response: `query`, `rows`, `has_more`, `next_before_id`, `listing`. |
| GET | `/api/emoji/{id}/animation` | Emoji Lottie animation (file). |
| GET | `/api/stickers` | Param: `kind` (string, type filter). Response: `rows`, `max_items`. |
| GET | `/api/stickers/{id}/documents` | Response: `document_ids`. |
| GET | `/api/stickers/documents/{id}/animation` | Sticker animation (file). |
| GET | `/api/gif-catalog` | Response: proxied from Admin API (`/v1/gif-catalog`). |
| GET | `/api/gif-catalog/documents/{id}/preview` | GIF preview (file). |
## API: message audit
| Method | Path | Parameters / response |
| --- | --- | --- |
| GET | `/api/messages` | Params: `owner_user_id` (int64, required with `peer_id`), `peer_id` (int64), `before_date` (int64), `before_id` (int), `limit` (int). Response: `owner_user_id`, `peer_id`, `before_date`, `before_id`, `limit`, `rows`. |
| GET | `/api/messages/detail` | Params: `owner_user_id` (int64, req.), `msg_id` (int, req.). Response: message card. |
| GET | `/api/messages/groups` | Params: `channel_id` (int64, req.), `before_date` (int64), `before_id` (int), `limit` (int). Response: `channel_id`, `before_date`, `before_id`, `limit`, `rows`. |
| GET | `/api/messages/groups/detail` | Params: `channel_id` (int64, req.), `msg_id` (int, req.). Response: message card. |
## API: star gifts and collectibles
| Method | Path | Parameters / response |
| --- | --- | --- |
| GET | `/api/gifts` | Response: `Gifts` (gift list). |
| GET | `/api/auctions` | Response: `Auctions` — live state of all operator-authored auctions and scheduled drops. |
| GET | `/api/official-gifts` | Response: proxied from Admin API (`/v1/official-gifts`). |
| GET | `/api/official-gifts/{id}/animation` | Official gift animation (file). |
| GET | `/api/gifts/{id}/animation` | Gift animation (file). |
| GET | `/api/gifts/{id}/collectibles` | Response: proxied from Admin API (`/v1/gifts/{id}/collectibles`). |
| GET | `/api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation` | Collectible attribute animation (file). `kind` ∈ {`model`, `pattern`}. |
| GET | `/api/collectible-usernames` | Params: `status` (`` | `vault` | `owned` | `burned`), `owner_user_id` (int64), `before_id` (int64), `limit` (int), `q`. Response: `rows`, `has_more`, `next_before_id`. |
| GET | `/api/collectible-usernames/{id}` | Response: `asset`, `transfers`. |
| GET | `/api/collectible-phones` | Params are passed through to the Admin API as-is (`/v1/collectible-phones?...`). |
| GET | `/api/collectible-phones/{id}` | Params are passed through to the Admin API (`/v1/collectible-phones/{id}?...`). |
## API: Premium
All routes in this section require the `premium.manage` permission (checked both
at the panel and at the Admin API).
| Method | Path | Description |
| --- | --- | --- |
| GET | `/api/premium/plans` | Premium plan catalog (proxied to Admin API `/v1/premium/plans`). |
## API: moderation
All reads pass query parameters through to the Admin API as-is
(`/v1/moderation/...`). Decisions are `POST` (see below).
| Method | Path | Description |
| --- | --- | --- |
| GET | `/api/moderation/cases` | Moderation case list. |
| GET | `/api/moderation/cases/{id}` | Moderation case. |
| GET | `/api/moderation/reports/{id}` | Report. |
| POST | `/api/moderation/cases/{id}/claim` | Claim the case. Body: common fields + `version` (int64), `internal_note` (string). |
| POST | `/api/moderation/cases/{id}/decide` | Decide the case. Body: common fields + `version` (int64), `internal_note` (string). |
| POST | `/api/moderation/cases/{id}/appeals/{appeal_id}/review` | Review an appeal. Body: common fields + `version` (int64), `internal_note` (string). |
## API: official verification
All routes require the `verification.review` permission. Reads go straight to
PostgreSQL; decisions always go through the Admin API (command journal, state
machine, optimistic locking).
| Method | Path | Parameters / body |
| --- | --- | --- |
| GET | `/api/verification/applications` | Params: `status`, `target_type`, `reviewer`, `q`, `before_id`, `limit`. Response: `rows`, `has_more`, `next_before_id`. |
| GET | `/api/verification/applications/{id}` | Response: application, events, `applicant_controls_target`, `target_verified`. |
| GET | `/api/verification/counts` | Application counts by status. |
| POST | `/api/verification/applications/{id}/claim` | Decision body: common fields + `version` (int64), `internal_note` (string). |
| POST | `/api/verification/applications/{id}/approve` | Body: common fields + `version` (int64), `internal_note` (string). Grants badge. |
| POST | `/api/verification/applications/{id}/reject` | Body: common fields + `version` (int64), `internal_note` (string). |
| POST | `/api/actions/revoke-verification` | Strip a badge. Requires `verification.review` **and** `verification.revoke`. Body — see actions section below. |
A "decided by another moderator" conflict is returned as `409 Conflict`.
## API: third-party (bot) verification
A separate mechanism with separate tables, permissions, and routes. Queue reads
and decisions require `botverification.review`; managing verifiers, the icon
catalog, and stripping marks requires `botverification.manage`.
| Method | Path | Parameters / body |
| --- | --- | --- |
| GET | `/api/botverification/verifiers` | Params: `enabled_only` (bool), `limit` (int). Response: `rows`. |
| GET | `/api/botverification/icons` | Params: `active_only` (bool), `limit` (int). Response: `rows`. |
| GET | `/api/botverification/marks` | Params: `peer_type`, `verifier_bot_id` (int64), `q`, `before_id` (int64), `limit` (int). Response: `rows`, `has_more`, `next_before_id`. |
| GET | `/api/botverification/requests` | Params: `status`, `peer_type`, `verifier_bot_id` (int64), `q`, `before_id` (int64), `limit` (int). Response: `rows`, `has_more`, `next_before_id`. |
| GET | `/api/botverification/requests/{id}` | Response: `request`, `verifier`, `mark_active`. |
| GET | `/api/botverification/counts` | Request counts by status. Response: `counts`. |
| POST | `/api/botverification/requests/{id}/approve` | Decision body: common fields + `version` (int64), `internal_note` (string). |
| POST | `/api/botverification/requests/{id}/reject` | Body: common fields + `version` (int64), `internal_note` (string). |
| POST | `/api/botverification/requests/{id}/revoke` | Body: common fields + `version` (int64), `internal_note` (string). |
| POST | `/api/actions/grant-bot-verifier` | Appoint a bot as verifier. Requires `botverification.manage`. Body — see below. |
| POST | `/api/actions/set-bot-verifier-enabled` | Enable/disable a verifier. Requires `botverification.manage`. Body — see below. |
| POST | `/api/actions/revoke-bot-verifier` | Strip a bot's verifier status. Requires `botverification.manage`. Body — see below. |
| POST | `/api/actions/upsert-verification-icon` | Add/edit a catalog icon. Requires `botverification.manage`. Body — see below. |
| POST | `/api/actions/set-verification-icon-active` | Enable/disable an icon. Requires `botverification.manage`. Body — see below. |
| POST | `/api/actions/revoke-custom-verification` | Strip a third-party mark. Requires `botverification.manage`. Body — see below. |
---
## API: account actions
All routes are `POST /api/actions/...`, require a session and CSRF. The request
body always contains the **common command fields** (`command_id`, `reason`,
`confirm`) plus the fields from the table below.
| Path | Specific body fields | Permission |
| --- | --- | --- |
| `set-frozen` | `user_id` (int64), `frozen` (bool), `freeze_until` (time, opt.), `freeze_appeal_url` (string, opt.) | — |
| `grant-premium` | `user_id` (int64), `months` (int) | `premium.manage` |
| `upsert-premium-plan` | `months` (int), `duration_days` (int), `amount_stars` (int64), `fiat_currency` (string), `fiat_amount` (int64), `store_product` (string), `store_quantity` (int), `enabled` (bool), `sort_order` (int), `label` (string), `expected_version` (int64) | `premium.manage` |
| `grant-stars` | `user_id` (int64), `amount` (int64) | — |
| `set-verified` | `user_id` (int64), `verified` (bool) | — |
| `set-account-flags` | `user_id` (int64), `scam` (bool), `fake` (bool) | — |
| `set-support` | `user_id` (int64), `support` (bool) | — |
| `set-account-username` | `user_id` (int64), `username` (string) | — |
| `set-account-profile` | `user_id` (int64), `first_name` (string), `last_name` (string) | — |
| `set-account-phone` | `user_id` (int64), `phone` (string) | — |
| `set-account-login-email` | `user_id` (int64), `email` (string) | — |
| `set-account-avatar` | **multipart**: `metadata` field (JSON with common fields + `user_id` (int64)) and a file in `file` | — |
| `set-account-color` | `user_id` (int64), `for_profile` (bool), `has_color` (bool), `color` (int), `background_emoji_id` (int64) | — |
| `set-account-emoji-status` | `user_id` (int64), `document_id` (int64), `until` (int, seconds) | — |
| `revoke-sessions` | `user_id` (int64), `hash` (int64, opt.), `keep_hash` (int64, opt.), `revoke_all` (bool) | — |
Example (granting Premium, the case from above):
```http
POST /api/actions/grant-premium
Content-Type: application/json
X-CSRF-Token: <csrf from /api/session>
{
"command_id": "grant-premium-001",
"reason": "Incident compensation",
"confirm": true,
"user_id": 123456789,
"months": 12
}
```
## API: channel actions
| Path | Specific body fields | Permission |
| --- | --- | --- |
| `set-channel-flags` | `channel_id` (int64), `scam` (bool), `fake` (bool) | — |
| `set-channel-settings` | `channel_id` (int64), `gigagroup` (*bool), `antispam` (*bool), `participants_hidden` (*bool), `noforwards` (*bool), `join_to_send` (*bool), `join_request` (*bool), `slowmode_seconds` (*int) — all optional pointers | — |
| `set-channel-username` | `channel_id` (int64), `username` (string) | — |
| `set-channel-color` | `channel_id` (int64), `for_profile` (bool), `has_color` (bool), `color` (int), `background_emoji_id` (int64) | — |
| `set-channel-emoji-status` | `channel_id` (int64), `document_id` (int64), `until` (int) | — |
| `set-channel-avatar` | **multipart**: `metadata` (JSON with common fields + `channel_id` (int64)) and a file `file` | — |
| `set-channel-verified` | `channel_id` (int64), `verified` (bool) | — |
## API: bot actions
| Path | Specific body fields | Permission |
| --- | --- | --- |
| `create-bot` | `owner_user_id` (int64), `name` (string), `username` (string) | — |
| `delete-bot` | `bot_user_id` (int64) | — |
| `export-bot-token` | `bot_user_id` (int64) | `bots.token.read` |
| `create-broadcast` | `message` (string), `target_mode` (string), `user_ids` ([]int64, opt.) | — |
## API: sticker and emoji actions
| Path | Specific body fields | Permission |
| --- | --- | --- |
| `create-sticker-set` | **multipart**: `metadata` (JSON: common fields + `title`, `short_name`, `kind`, `emoji`, `keywords`) and a file `file` | — |
| `rename-sticker-set` | `set_id` (int64), `title` (string) | — |
| `add-sticker-to-set` | **multipart**: `metadata` (JSON: common fields + `set_id` (string), `emoji`, `keywords`) and a file `file` | — |
| `remove-sticker-from-set` | `set_id` (int64), `document_id` (int64) | — |
| `set-sticker-set-archived` | `set_id` (int64), `archived` (bool) | — |
| `set-sticker-set-sort-order` | `set_id` (int64), `sort_order` (int) | — |
| `delete-sticker-set` | `set_id` (int64) | — |
## API: GIF catalog actions
| Path | Specific body fields | Permission |
| --- | --- | --- |
| `create-gif-catalog-entry` | **multipart**: `metadata` (JSON: common fields + `title`) and a file `file` | — |
| `set-gif-catalog-enabled` | `id` (int64, sent as string), `enabled` (bool) | — |
| `set-gif-catalog-sort-order` | `id` (int64, string), `sort_order` (int) | — |
| `delete-gif-catalog-entry` | `id` (int64, string) | — |
## API: gift and collectible actions
| Path | Specific body fields | Permission |
| --- | --- | --- |
| `import-gift` | **multipart**: `metadata` (JSON: common fields + `gift_id` (int64), `title`, `stars` (int64), `convert_stars` (int64), `enabled` (bool), `sort_order` (int), `auction` (bool), `auction_slug`, `gifts_per_round` (int), `auction_start_date` (int), `auction_round_duration` (int), `availability_total` (int), `locked_until_date` (int)) and a file `file` | — |
| `import-official-gift` | common fields + `source_gift_id` (string), `gift_id` (int64), `title`, `stars` (int64), `convert_stars` (int64), `enabled` (bool), `sort_order` (int), `include_collectible` (bool), `upgrade_stars` (int64), `supply_total` (int), `slug_prefix` (string), `locked_until_date` (int) | — |
| `publish-gift-collectibles` | **multipart**, `gift_id` sent in query (`?gift_id=`): `metadata` (JSON: common fields + `upgrade_stars` (int64), `supply_total` (int), `slug_prefix` (string), `models` ([]object), `patterns` ([]object), `backdrops` ([]object)); animations are files keyed by the `models`/`patterns` entries | — |
| `set-gift-enabled` | `gift_id` (int64), `enabled` (bool) | — |
| `set-gift-sort-order` | `gift_id` (int64), `sort_order` (int) | — |
| `give-gift` | common fields + `sender_user_id` (int64), `user_id` (int64), `channel_id` (int64), `gift_id` (int64), `hide_name` (bool), `message` (string), `upgrade` (bool), `model_attribute_id` (int64), `pattern_attribute_id` (int64), `backdrop_attribute_id` (int64) | — |
## API: collectible username actions
| Path | Specific body fields | Permission |
| --- | --- | --- |
| `mint-collectible-username` | `username` (string), `owner_user_id` (int64), `owner_channel_id` (int64), `currency` (string), `amount` (int64), `crypto_currency` (string), `crypto_amount` (int64), `url` (string), `purchase_date` (int/unix or date) | — |
| `transfer-collectible-username` | `username` (string), `to_user_id` (int64), `to_channel_id` (int64) | — |
| `revoke-collectible-username` | `username` (string), `burn` (bool) | — |
| `delete-collectible-username` | `username` (string) | — |
## API: anonymous number actions
| Path | Specific body fields | Permission |
| --- | --- | --- |
| `mint-collectible-phone` | `phone` (string), `tier` (string), `owner_user_id` (int64), `currency` (string), `amount` (int64), `crypto_currency` (string), `crypto_amount` (int64), `url` (string), `purchase_date` (int/unix or date) | — |
| `update-collectible-phone-price` | `phone` (string), `currency` (string), `amount` (int64), `crypto_currency` (string), `crypto_amount` (int64) | — |
| `transfer-collectible-phone` | `phone` (string), `to_user_id` (int64) | — |
| `revoke-collectible-phone` | `phone` (string), `burn` (bool) | — |
| `delete-collectible-phone` | `phone` (string) | — |
## API: account rating actions
| Path | Specific body fields | Permission |
| --- | --- | --- |
| `recompute-account-rating` | `user_id` (int64) | — |
| `adjust-account-rating` | `user_id` (int64), `amount` (int64) | — |
## API: message deletion
| Path | Specific body fields | Permission |
| --- | --- | --- |
| `delete-messages` | `owner_user_id` (int64), `peer_id` (int64), `ids` ([]int), `revoke` (bool) | — |
| `delete-history` | `owner_user_id` (int64), `peer_id` (int64), `max_id` (int), `min_date` (int), `max_date` (int), `max_batches` (int), `just_clear` (bool), `revoke` (bool) | — |
## API: verification decisions (request bodies)
Official verification — `revoke-verification`:
| Field | Type | Description |
| --- | --- | --- |
| `target_type` | string | Target type (`user` / `channel`, etc., validated by `domain.VerificationTargetType.Valid()`). |
| `target_id` | int64 | Target identifier (not the application!). |
| `internal_note` | string | Operator-only internal note (opt.). |
| + common fields | | `command_id`, `reason`, `confirm`. |
Third-party verification — verifier/mark actions (`botverification.manage`):
| Path | Specific body fields |
| --- | --- |
| `grant-bot-verifier` | `bot_id` (int64), `icon_document_id` (int64), `company_name` (string, req.), `default_description` (string), `can_modify_custom_description` (bool), `version` (int64, 0 for new) |
| `set-bot-verifier-enabled` | `bot_id` (int64), `enabled` (bool) |
| `revoke-bot-verifier` | `bot_id` (int64) |
| `upsert-verification-icon` | `document_id` (int64), `name` (string, req.), `owner_bot_id` (int64, opt., 0 = shared) |
| `set-verification-icon-active` | `icon_id` (int64), `active` (bool) |
| `revoke-custom-verification` | `verifier_bot_id` (int64), `peer_type` (string, validated), `peer_id` (int64) |
All of these also carry the common fields (`command_id`, `reason`, `confirm`).

416
docs/admin-panel-api.ru.md Normal file
View file

@ -0,0 +1,416 @@
# Админ-панель: все пути и контракты API
Документация по всем путям встроенной админ-панели `telesrv` — адресам
страниц (SPA) и API-маршрутам, которые за ними стоят. Сервер панели живёт в
`cmd/telesrv-admin`, маршруты объявлены в функции `(*server).routes()`
(`server.go:50`).
Панель — это обычный HTTP-сервер: он отдаёт собранный фронтенд (`/`) и JSON-API
под префиксом `/api/`. Все API-пути требуют сессию (cookie), а все
изменяющие запросы — ещё и CSRF-токен. Многие маршруты дополнительно проверяют
права оператора (`permission`).
## Как пользоваться этим документом
- Для каждого GET-эндпоинта указаны **параметры запроса** (query) и краткая
форма **ответа**.
- Для каждого POST-эндпоинта в блоке `/api/actions/*` и решений верификации
указано **тело запроса** (JSON). Все они разделяют общие поля
(см. ниже) плюс свои специфичные поля.
- `int64` в JSON можно передавать и как число, и как строку (например,
`"user_id": "123"` или `"user_id": 123`) — сервер принимает оба варианта
(`flexInt64`). `flexUnix` принимает Unix-секунды или дату `2006-01-02` /
RFC3339.
- Все изменяющие запросы идут в Admin API. Ответ команды — либо
`{"status":..., "message":..., "command_id":...}` при успехе (HTTP 200), либо
`{"error":..., "code":...}` при ошибке. Конфликт оптимистичной блокировки —
`409`.
## Аутентификация и сессия
| Метод | Путь | Описание |
| --- | --- | --- |
| POST | `/api/login` | Вход. Тело: `{"secret": "..."}`. Проверяет секрет из конфига, выдаёт подписанную cookie-сессию и cookie CSRF. Единственный изменяющий маршрут без CSRF-токена; проверяется только Origin. |
| POST | `/api/logout` | Выход. Уничтожает сессию и cookie CSRF. |
| GET | `/api/session` | Возвращает `{"actor": "...", "permissions": [...], "csrf_token": "..."}`. Панель вызывает его при старте. |
Сессия живёт в подписанной cookie (TTL по умолчанию 12 часов), серверного
хранилища сессий нет. Все запросы, кроме `GET/HEAD/OPTIONS` и `/api/login`,
обязаны предъявить:
- cookie `telesrv_admin_csrf`;
- заголовок `X-CSRF-Token` с тем же значением;
- Origin, совпадающий с хостом (если Origin присутствует).
Нарушение любого из трёх условий — `403 Forbidden`.
## Права и их проверка
Панель различает обычную авторизацию (`requireAuthAPI`) и проверку права
(`requirePermission`). Имена прав совпадают со строками в
`TELESRV_ADMIN_UI_PERMISSIONS`:
| Право | Что даёт |
| --- | --- |
| `*` | Все права (wildcard). |
| `premium.manage` | Управление каталогом Premium-планов, начисление Premium. |
| `bots.token.read` | Экспорт токена бота (`/api/actions/export-bot-token`). |
| `verification.review` | Чтение и решение очереди официальной верификации. |
| `verification.revoke` | Снятие официального бейджа (в дополнение к `verification.review`). |
| `botverification.review` | Чтение и решение очереди сторонней (ботовой) верификации. |
| `botverification.manage` | Назначение верификаторов, редактирование каталога иконок, снятие метки сторонней верификации. |
Права официальной и сторонней верификации намеренно независимы. Список прав
сессии возвращается в `/api/session`, чтобы интерфейс скрывал недоступные
разделы.
## Страницы интерфейса (SPA)
SPA-роуты декларативны: любое имя раздела отдаётся сервером как `/` (index.html),
а сам фронтенд решает, что рендерить (`web/src/pages/Routes.tsx`).
| Путь | Страница |
| --- | --- |
| `/` | Панель управления (счётчики, статистика хранилища, ссылки на разделы). |
| `/accounts` | Список аккаунтов (с поиском). |
| `/accounts/{id}` | Карточка аккаунта: профиль, действия. |
| `/channels` | Список супергрупп и каналов. |
| `/channels/{id}` | Карточка канала. |
| `/bots` | Список ботов. |
| `/bots/{id}` | Карточка бота. |
| `/broadcasts` | Рассылки. |
| `/monetization`, `/premium` | Звёзды и Premium: планы, начисление. Требует `premium.manage`. |
| `/moderation` | Жалобы и модерация: список кейсов. |
| `/moderation/{id}` | Детали кейса модерации. |
| `/emoji` | Каталог emoji-наборов. |
| `/stickers` | Каталог стикерпаков. |
| `/gif-catalog` | Каталог GIF. |
| `/messages`, `/messages/private` | Аудит личных сообщений. |
| `/messages/detail`, `/messages/private/detail` | Детали личного сообщения (`?owner_user_id=&msg_id=`). |
| `/messages/groups` | Аудит сообщений групп/каналов. |
| `/messages/groups/detail` | Детали сообщения в группе (`?channel_id=&msg_id=`). |
| `/gifts` | Звёздные подарки: каталог, коллекционки, аукционы. |
| `/give-gifts` | Выдача подарков. |
| `/collectible-usernames` | Коллекционные юзернеймы. |
| `/collectible-usernames/{id}` | Карточка коллекционного юзернейма. |
| `/collectible-phones` | Анонимные номера. |
| `/account-ratings` | Рейтинг аккаунтов. |
| `/account-ratings/{user_id}` | Карточка рейтинга аккаунта. |
| `/storage` | Объектное хранилище: статистика. |
| `/verification` | Официальная верификация: очередь заявок. Требует `verification.review`. |
| `/verification/{id}` | Детали заявки на официальную верификацию. Требует `verification.review`. |
| `/bot-verification` | Сторонняя верификация: верификаторы, иконки, метки, очередь. Требует `botverification.review`. |
| `/bot-verification/{id}` | Детали запроса сторонней верификации. Требует `botverification.review`. |
Несуществующий API-путь возвращает `404 {"error":"api route not found"}`; любой
неизвестный путь фронтенда отдаётся как `/`.
## Соглашения ответов
- Успешные чтения — `200` + JSON; файлы (аватары, анимации, превью) — сам файл.
- Ошибки — JSON вида `{"error": "...", "code": "..."}` с соответствующим HTTP-статусом.
- `401` — нет/просрочена сессия; `403` — нарушение CSRF/Origin или не хватает права
`requirePermission` к телу добавляется поле `permission`);
`409` — конфликт оптимистичной блокировки (кейс модерации, верификация);
`502` — Admin API недоступен.
- Ошибки команд, ушедших в Admin API, возвращаются как
`{"status": ..., "message": ..., "error": ...}`.
## Общие поля команд (POST `/api/actions/*`)
Каждый изменяющий запрос несёт в теле:
| Поле | Тип | Описание |
| --- | --- | --- |
| `command_id` | string | Идемпотентный ключ команды. Повтор с тем же `command_id` не выполняет действие дважды. Если пусто — генерируется сервером. |
| `reason` | string | Обязательная причина операции (аудит). |
| `confirm` | bool | Подтверждение оператором (`true`). |
---
## API: дашборд и хранилище
| Метод | Путь | Описание |
| --- | --- | --- |
| GET | `/api/dashboard` | Сводка: `counts`, `storage`, при наличии — `host`. |
| GET | `/api/storage/stats` | Статистика объектного хранилища. |
## API: аккаунты
| Метод | Путь | Параметры / ответ |
| --- | --- | --- |
| GET | `/api/accounts` | Параметры: `q` (поиск), `before_id` (int64), `before_active_us` (int64, микросекунды), `limit` (int). Ответ: `query`, `limit`, `rows`, `has_more`, `next_before_id`, `next_before_active_us`, `listing`. |
| GET | `/api/accounts/{id}` | Карточка аккаунта: профиль, флаги, статистика (проксируется/читается из БД). |
| GET | `/api/accounts/{id}/avatar` | Аватар аккаунта (файл). |
| GET | `/api/account-ratings` | Параметры: `q`, `min_level` (int), `user_id` (int64), `before_id` (int64), `limit` (int). Ответ: `rows`, `has_more`, `next_before_id`. |
| GET | `/api/account-ratings/{user_id}` | Ответ: `rating`, `events`. |
## API: каналы и супергруппы
| Метод | Путь | Параметры / ответ |
| --- | --- | --- |
| GET | `/api/channels` | Параметры: `q`, `before_id` (int64), `before_updated_us` (int64, микросекунды), `limit` (int). Ответ: `query`, `limit`, `rows`, `has_more`, `next_before_id`, `next_before_updated_us`, `listing`. |
| GET | `/api/channels/{id}` | Карточка канала. |
| GET | `/api/channels/{id}/avatar` | Аватар канала (файл). |
## API: боты и рассылки
| Метод | Путь | Параметры / ответ |
| --- | --- | --- |
| GET | `/api/bots` | Параметры: `q`, `before_id` (int64), `limit` (int). Ответ: `query`, `limit`, `rows`, `has_more`, `next_before_id`, `listing`. |
| GET | `/api/bots/{id}` | Карточка бота. |
| GET | `/api/broadcasts` | Параметры: `before_id` (int64), `limit` (int). Ответ: `limit`, `rows`, `has_more`, `next_before_id`. |
## API: медиа-каталоги
| Метод | Путь | Параметры / ответ |
| --- | --- | --- |
| GET | `/api/emoji` | Параметры: `q`, `before_id` (int64), `limit` (int). Ответ: `query`, `rows`, `has_more`, `next_before_id`, `listing`. |
| GET | `/api/emoji/{id}/animation` | Lottie-анимация emoji (файл). |
| GET | `/api/stickers` | Параметр: `kind` (string, фильтр по типу). Ответ: `rows`, `max_items`. |
| GET | `/api/stickers/{id}/documents` | Ответ: `document_ids`. |
| GET | `/api/stickers/documents/{id}/animation` | Анимация стикера (файл). |
| GET | `/api/gif-catalog` | Ответ: проксируется из Admin API (`/v1/gif-catalog`). |
| GET | `/api/gif-catalog/documents/{id}/preview` | Превью GIF (файл). |
## API: аудит сообщений
| Метод | Путь | Параметры / ответ |
| --- | --- | --- |
| GET | `/api/messages` | Параметры: `owner_user_id` (int64, обязателен с `peer_id`), `peer_id` (int64), `before_date` (int64), `before_id` (int), `limit` (int). Ответ: `owner_user_id`, `peer_id`, `before_date`, `before_id`, `limit`, `rows`. |
| GET | `/api/messages/detail` | Параметры: `owner_user_id` (int64, обяз.), `msg_id` (int, обяз.). Ответ: карточка сообщения. |
| GET | `/api/messages/groups` | Параметры: `channel_id` (int64, обяз.), `before_date` (int64), `before_id` (int), `limit` (int). Ответ: `channel_id`, `before_date`, `before_id`, `limit`, `rows`. |
| GET | `/api/messages/groups/detail` | Параметры: `channel_id` (int64, обяз.), `msg_id` (int, обяз.). Ответ: карточка сообщения. |
## API: звёздные подарки и коллекционки
| Метод | Путь | Параметры / ответ |
| --- | --- | --- |
| GET | `/api/gifts` | Ответ: `Gifts` (список подарков). |
| GET | `/api/auctions` | Ответ: `Auctions` — текущее состояние всех авторских аукционов и запланированных дропов. |
| GET | `/api/official-gifts` | Ответ: проксируется из Admin API (`/v1/official-gifts`). |
| GET | `/api/official-gifts/{id}/animation` | Анимация официального подарка (файл). |
| GET | `/api/gifts/{id}/animation` | Анимация подарка (файл). |
| GET | `/api/gifts/{id}/collectibles` | Ответ: проксируется из Admin API (`/v1/gifts/{id}/collectibles`). |
| GET | `/api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation` | Анимация атрибута коллекционки (файл). `kind` ∈ {`model`, `pattern`}. |
| GET | `/api/collectible-usernames` | Параметры: `status` (`` | `vault` | `owned` | `burned`), `owner_user_id` (int64), `before_id` (int64), `limit` (int), `q`. Ответ: `rows`, `has_more`, `next_before_id`. |
| GET | `/api/collectible-usernames/{id}` | Ответ: `asset`, `transfers`. |
| GET | `/api/collectible-phones` | Параметры пробрасываются в Admin API как есть (`/v1/collectible-phones?...`). |
| GET | `/api/collectible-phones/{id}` | Параметры пробрасываются в Admin API (`/v1/collectible-phones/{id}?...`). |
## API: Premium
Все маршруты раздела требуют права `premium.manage` (проверяется и на уровне
панели, и на стороне Admin API).
| Метод | Путь | Описание |
| --- | --- | --- |
| GET | `/api/premium/plans` | Каталог Premium-планов (проксируется в Admin API `/v1/premium/plans`). |
## API: модерация
Все чтения пробрасывают query-параметры в Admin API как есть
(`/v1/moderation/...`). Решения — `POST` (см. ниже).
| Метод | Путь | Описание |
| --- | --- | --- |
| GET | `/api/moderation/cases` | Список кейсов модерации. |
| GET | `/api/moderation/cases/{id}` | Кейс модерации. |
| GET | `/api/moderation/reports/{id}` | Жалоба. |
| POST | `/api/moderation/cases/{id}/claim` | Взять кейс на себя. Тело: общие поля + `version` (int64), `internal_note` (string). |
| POST | `/api/moderation/cases/{id}/decide` | Решение по кейсу. Тело: общие поля + `version` (int64), `internal_note` (string). |
| POST | `/api/moderation/cases/{id}/appeals/{appeal_id}/review` | Рассмотрение апелляции. Тело: общие поля + `version` (int64), `internal_note` (string). |
## API: официальная верификация
Все маршруты требуют права `verification.review`. Чтения идут напрямую из
PostgreSQL, решения — всегда через Admin API (журнал команд, статусная машина,
оптимистичная блокировка).
| Метод | Путь | Параметры / тело |
| --- | --- | --- |
| GET | `/api/verification/applications` | Параметры: `status`, `target_type`, `reviewer`, `q`, `before_id`, `limit`. Ответ: `rows`, `has_more`, `next_before_id`. |
| GET | `/api/verification/applications/{id}` | Ответ: заявка, события, `applicant_controls_target`, `target_verified`. |
| GET | `/api/verification/counts` | Счётчики заявок по статусам. |
| POST | `/api/verification/applications/{id}/claim` | Тело решения: общие поля + `version` (int64), `internal_note` (string). |
| POST | `/api/verification/applications/{id}/approve` | Тело: общие поля + `version` (int64), `internal_note` (string). Выдаёт бейдж. |
| POST | `/api/verification/applications/{id}/reject` | Тело: общие поля + `version` (int64), `internal_note` (string). |
| POST | `/api/actions/revoke-verification` | Снять бейдж. Требует `verification.review` **и** `verification.revoke`. Тело — см. ниже в разделе действий. |
Конфликт «решил другой модератор» возвращается как `409 Conflict`.
## API: сторонняя (ботовая) верификация
Отдельный механизм с отдельными таблицами, правами и маршрутами. Чтения и
решения очереди требуют `botverification.review`; управление верификаторами,
каталогом иконок и снятие меток — `botverification.manage`.
| Метод | Путь | Параметры / тело |
| --- | --- | --- |
| GET | `/api/botverification/verifiers` | Параметры: `enabled_only` (bool), `limit` (int). Ответ: `rows`. |
| GET | `/api/botverification/icons` | Параметры: `active_only` (bool), `limit` (int). Ответ: `rows`. |
| GET | `/api/botverification/marks` | Параметры: `peer_type`, `verifier_bot_id` (int64), `q`, `before_id` (int64), `limit` (int). Ответ: `rows`, `has_more`, `next_before_id`. |
| GET | `/api/botverification/requests` | Параметры: `status`, `peer_type`, `verifier_bot_id` (int64), `q`, `before_id` (int64), `limit` (int). Ответ: `rows`, `has_more`, `next_before_id`. |
| GET | `/api/botverification/requests/{id}` | Ответ: `request`, `verifier`, `mark_active`. |
| GET | `/api/botverification/counts` | Счётчики запросов по статусам. Ответ: `counts`. |
| POST | `/api/botverification/requests/{id}/approve` | Тело решения: общие поля + `version` (int64), `internal_note` (string). |
| POST | `/api/botverification/requests/{id}/reject` | Тело: общие поля + `version` (int64), `internal_note` (string). |
| POST | `/api/botverification/requests/{id}/revoke` | Тело: общие поля + `version` (int64), `internal_note` (string). |
| POST | `/api/actions/grant-bot-verifier` | Назначить бота верификатором. Требует `botverification.manage`. Тело — см. ниже. |
| POST | `/api/actions/set-bot-verifier-enabled` | Включить/отключить верификатора. Требует `botverification.manage`. Тело — см. ниже. |
| POST | `/api/actions/revoke-bot-verifier` | Лишить бота статуса верификатора. Требует `botverification.manage`. Тело — см. ниже. |
| POST | `/api/actions/upsert-verification-icon` | Добавить/изменить иконку в каталоге. Требует `botverification.manage`. Тело — см. ниже. |
| POST | `/api/actions/set-verification-icon-active` | Включить/отключить иконку. Требует `botverification.manage`. Тело — см. ниже. |
| POST | `/api/actions/revoke-custom-verification` | Снять стороннюю метку. Требует `botverification.manage`. Тело — см. ниже. |
---
## API: действия над аккаунтами
Все маршруты — `POST /api/actions/...`, требуют сессию и CSRF. Тело запроса
всегда содержит **общие поля команд** (`command_id`, `reason`, `confirm`) плюс
поля из таблицы ниже.
| Путь | Специфичные поля тела | Право |
| --- | --- | --- |
| `set-frozen` | `user_id` (int64), `frozen` (bool), `freeze_until` (time, опц.), `freeze_appeal_url` (string, опц.) | — |
| `grant-premium` | `user_id` (int64), `months` (int) | `premium.manage` |
| `upsert-premium-plan` | `months` (int), `duration_days` (int), `amount_stars` (int64), `fiat_currency` (string), `fiat_amount` (int64), `store_product` (string), `store_quantity` (int), `enabled` (bool), `sort_order` (int), `label` (string), `expected_version` (int64) | `premium.manage` |
| `grant-stars` | `user_id` (int64), `amount` (int64) | — |
| `set-verified` | `user_id` (int64), `verified` (bool) | — |
| `set-account-flags` | `user_id` (int64), `scam` (bool), `fake` (bool) | — |
| `set-support` | `user_id` (int64), `support` (bool) | — |
| `set-account-username` | `user_id` (int64), `username` (string) | — |
| `set-account-profile` | `user_id` (int64), `first_name` (string), `last_name` (string) | — |
| `set-account-phone` | `user_id` (int64), `phone` (string) | — |
| `set-account-login-email` | `user_id` (int64), `email` (string) | — |
| `set-account-avatar` | **multipart**: поле `metadata` (JSON с общими полями + `user_id` (int64)) и файл в поле `file` | — |
| `set-account-color` | `user_id` (int64), `for_profile` (bool), `has_color` (bool), `color` (int), `background_emoji_id` (int64) | — |
| `set-account-emoji-status` | `user_id` (int64), `document_id` (int64), `until` (int, секунды) | — |
| `revoke-sessions` | `user_id` (int64), `hash` (int64, опц.), `keep_hash` (int64, опц.), `revoke_all` (bool) | — |
Пример (выдача Premium, как в вопросе выше):
```http
POST /api/actions/grant-premium
Content-Type: application/json
X-CSRF-Token: <csrf из /api/session>
{
"command_id": "grant-premium-001",
"reason": "Компенсация за инцидент",
"confirm": true,
"user_id": 123456789,
"months": 12
}
```
## API: действия над каналами
| Путь | Специфичные поля тела | Право |
| --- | --- | --- |
| `set-channel-flags` | `channel_id` (int64), `scam` (bool), `fake` (bool) | — |
| `set-channel-settings` | `channel_id` (int64), `gigagroup` (*bool), `antispam` (*bool), `participants_hidden` (*bool), `noforwards` (*bool), `join_to_send` (*bool), `join_request` (*bool), `slowmode_seconds` (*int) — все опциональные указатели | — |
| `set-channel-username` | `channel_id` (int64), `username` (string) | — |
| `set-channel-color` | `channel_id` (int64), `for_profile` (bool), `has_color` (bool), `color` (int), `background_emoji_id` (int64) | — |
| `set-channel-emoji-status` | `channel_id` (int64), `document_id` (int64), `until` (int) | — |
| `set-channel-avatar` | **multipart**: `metadata` (JSON с общими полями + `channel_id` (int64)) и файл `file` | — |
| `set-channel-verified` | `channel_id` (int64), `verified` (bool) | — |
## API: действия над ботами
| Путь | Специфичные поля тела | Право |
| --- | --- | --- |
| `create-bot` | `owner_user_id` (int64), `name` (string), `username` (string) | — |
| `delete-bot` | `bot_user_id` (int64) | — |
| `export-bot-token` | `bot_user_id` (int64) | `bots.token.read` |
| `create-broadcast` | `message` (string), `target_mode` (string), `user_ids` ([]int64, опц.) | — |
## API: действия над стикерами и emoji
| Путь | Специфичные поля тела | Право |
| --- | --- | --- |
| `create-sticker-set` | **multipart**: `metadata` (JSON: общие поля + `title`, `short_name`, `kind`, `emoji`, `keywords`) и файл `file` | — |
| `rename-sticker-set` | `set_id` (int64), `title` (string) | — |
| `add-sticker-to-set` | **multipart**: `metadata` (JSON: общие поля + `set_id` (string), `emoji`, `keywords`) и файл `file` | — |
| `remove-sticker-from-set` | `set_id` (int64), `document_id` (int64) | — |
| `set-sticker-set-archived` | `set_id` (int64), `archived` (bool) | — |
| `set-sticker-set-sort-order` | `set_id` (int64), `sort_order` (int) | — |
| `delete-sticker-set` | `set_id` (int64) | — |
## API: действия над GIF-каталогом
| Путь | Специфичные поля тела | Право |
| --- | --- | --- |
| `create-gif-catalog-entry` | **multipart**: `metadata` (JSON: общие поля + `title`) и файл `file` | — |
| `set-gif-catalog-enabled` | `id` (int64, передаётся как строка), `enabled` (bool) | — |
| `set-gif-catalog-sort-order` | `id` (int64, строка), `sort_order` (int) | — |
| `delete-gif-catalog-entry` | `id` (int64, строка) | — |
## API: действия над подарками и коллекционками
| Путь | Специфичные поля тела | Право |
| --- | --- | --- |
| `import-gift` | **multipart**: `metadata` (JSON: общие поля + `gift_id` (int64), `title`, `stars` (int64), `convert_stars` (int64), `enabled` (bool), `sort_order` (int), `auction` (bool), `auction_slug`, `gifts_per_round` (int), `auction_start_date` (int), `auction_round_duration` (int), `availability_total` (int), `locked_until_date` (int)) и файл `file` | — |
| `import-official-gift` | общие поля + `source_gift_id` (string), `gift_id` (int64), `title`, `stars` (int64), `convert_stars` (int64), `enabled` (bool), `sort_order` (int), `include_collectible` (bool), `upgrade_stars` (int64), `supply_total` (int), `slug_prefix` (string), `locked_until_date` (int) | — |
| `publish-gift-collectibles` | **multipart**, `gift_id` передаётся в query (`?gift_id=`): `metadata` (JSON: общие поля + `upgrade_stars` (int64), `supply_total` (int), `slug_prefix` (string), `models` ([]объект), `patterns` ([]объект), `backdrops` ([]объект)); анимации — файлы по ключам из `models`/`patterns` | — |
| `set-gift-enabled` | `gift_id` (int64), `enabled` (bool) | — |
| `set-gift-sort-order` | `gift_id` (int64), `sort_order` (int) | — |
| `give-gift` | общие поля + `sender_user_id` (int64), `user_id` (int64), `channel_id` (int64), `gift_id` (int64), `hide_name` (bool), `message` (string), `upgrade` (bool), `model_attribute_id` (int64), `pattern_attribute_id` (int64), `backdrop_attribute_id` (int64) | — |
## API: действия над коллекционными юзернеймами
| Путь | Специфичные поля тела | Право |
| --- | --- | --- |
| `mint-collectible-username` | `username` (string), `owner_user_id` (int64), `owner_channel_id` (int64), `currency` (string), `amount` (int64), `crypto_currency` (string), `crypto_amount` (int64), `url` (string), `purchase_date` (int/unix или дата) | — |
| `transfer-collectible-username` | `username` (string), `to_user_id` (int64), `to_channel_id` (int64) | — |
| `revoke-collectible-username` | `username` (string), `burn` (bool) | — |
| `delete-collectible-username` | `username` (string) | — |
## API: действия над анонимными номерами
| Путь | Специфичные поля тела | Право |
| --- | --- | --- |
| `mint-collectible-phone` | `phone` (string), `tier` (string), `owner_user_id` (int64), `currency` (string), `amount` (int64), `crypto_currency` (string), `crypto_amount` (int64), `url` (string), `purchase_date` (int/unix или дата) | — |
| `update-collectible-phone-price` | `phone` (string), `currency` (string), `amount` (int64), `crypto_currency` (string), `crypto_amount` (int64) | — |
| `transfer-collectible-phone` | `phone` (string), `to_user_id` (int64) | — |
| `revoke-collectible-phone` | `phone` (string), `burn` (bool) | — |
| `delete-collectible-phone` | `phone` (string) | — |
## API: действия над рейтингом аккаунтов
| Путь | Специфичные поля тела | Право |
| --- | --- | --- |
| `recompute-account-rating` | `user_id` (int64) | — |
| `adjust-account-rating` | `user_id` (int64), `amount` (int64) | — |
## API: удаление сообщений
| Путь | Специфичные поля тела | Право |
| --- | --- | --- |
| `delete-messages` | `owner_user_id` (int64), `peer_id` (int64), `ids` ([]int), `revoke` (bool) | — |
| `delete-history` | `owner_user_id` (int64), `peer_id` (int64), `max_id` (int), `min_date` (int), `max_date` (int), `max_batches` (int), `just_clear` (bool), `revoke` (bool) | — |
## API: решения верификации (тела запросов)
Официальная верификация — `revoke-verification`:
| Поле | Тип | Описание |
| --- | --- | --- |
| `target_type` | string | Тип цели (`user` / `channel` и т.п., валидируется `domain.VerificationTargetType.Valid()`). |
| `target_id` | int64 | Идентификатор цели (не заявки!). |
| `internal_note` | string | Внутренняя заметка оператора (опц.). |
| + общие поля | | `command_id`, `reason`, `confirm`. |
Сторонняя верификация — действия верификатора/меток (`botverification.manage`):
| Путь | Специфичные поля тела |
| --- | --- |
| `grant-bot-verifier` | `bot_id` (int64), `icon_document_id` (int64), `company_name` (string, обяз.), `default_description` (string), `can_modify_custom_description` (bool), `version` (int64, 0 для нового) |
| `set-bot-verifier-enabled` | `bot_id` (int64), `enabled` (bool) |
| `revoke-bot-verifier` | `bot_id` (int64) |
| `upsert-verification-icon` | `document_id` (int64), `name` (string, обяз.), `owner_bot_id` (int64, опц., 0 = общая) |
| `set-verification-icon-active` | `icon_id` (int64), `active` (bool) |
| `revoke-custom-verification` | `verifier_bot_id` (int64), `peer_type` (string, валидируется), `peer_id` (int64) |
Все они также несут общие поля (`command_id`, `reason`, `confirm`).

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

83
docs/local-setup.md Normal file
View file

@ -0,0 +1,83 @@
# Local setup
This guide shows the shortest safe path for running gramsrv on a development
machine or a small test server.
## 1. Prepare local configuration
The repository intentionally tracks only `.env.example`. Your real `.env` is
ignored by Git and must not be committed.
Linux / macOS:
```bash
cp .env.example .env
${EDITOR:-nano} .env
```
Windows PowerShell:
```powershell
Copy-Item .env.example .env
notepad .env
```
If you prefer a different config filename, set `TELESRV_CONFIG` as a process
environment variable before starting the server.
## 2. Set the network values
Review at least these values in `.env`:
- `TELESRV_LISTEN` is the MTProto bind address. Use `0.0.0.0:2398` when
external clients must connect to this host, or `127.0.0.1:2398` for
same-machine testing only.
- `TELESRV_ADVERTISE_IP` must be a client-reachable IPv4 or IPv6 address, not a
DNS name. Use `127.0.0.1` only when the patched client runs on the same
machine. Use a LAN or public IP for phones, other computers, or remote tests.
- `TELESRV_PUBLIC_BASE_URL` and `TELESRV_PUBLIC_WEB_BASE_URL` are HTTP(S) URLs
used in generated public links. Put hostnames here, not in
`TELESRV_ADVERTISE_IP`.
- `TELESRV_DEV_AUTH_CODE=12345` is convenient for local development but must not
be exposed as a production login code.
## 3. Start Postgres and Redis
The development compose file exposes Postgres on `127.0.0.1:5432` and Redis on
`127.0.0.1:6399`, matching the defaults in `.env.example`.
```bash
docker compose -f deploy/docker-compose.yml up -d
```
If you use external Postgres or Redis, update `TELESRV_POSTGRES_DSN` and
`TELESRV_REDIS_ADDR` in `.env`.
## 4. Build and run the server
Linux / macOS:
```bash
go build -o bin/gramsrv ./cmd/telesrv
./bin/gramsrv
```
Windows PowerShell:
```powershell
go build -o bin/gramsrv.exe ./cmd/telesrv
.\bin\gramsrv.exe
```
## 5. First-start checklist
After startup, confirm:
- migrations completed successfully;
- `data/server_rsa.pem` was created if it did not already exist;
- MTProto is listening on `TELESRV_LISTEN`;
- Postgres and Redis connections are healthy;
- patched clients use the matching DC address, port, and server RSA key.
For the complete configuration reference, see
[`docs/configuration.en.md`](configuration.en.md).

175
docs/update-service.md Normal file
View file

@ -0,0 +1,175 @@
# Native client update service
`cmd/telesrv-update` is a standalone HTTP service that provides two
Telegram-compatible update surfaces:
- `/current4` and `/files/*` for the built-in Telegram Desktop updater;
- `/v1/resolve` for the main `telesrv` process to answer
`help.getAppUpdate` for Android, iOS, and other supported builds.
The service has no HTTP upload endpoint. Operators publish a release by placing
an immutable artifact in the configured `files` directory and atomically
replacing `manifest.json`. The catalog validates the file size and SHA-256
before exposing it, so truncated or accidentally replaced packages fail closed.
## Quick start
Create the working directories and start with the disabled example catalog:
```powershell
New-Item -ItemType Directory -Force data\updates\files
Copy-Item deploy\update\manifest.example.json data\updates\manifest.json
go run ./cmd/telesrv-update -check
go run ./cmd/telesrv-update
```
Check the local endpoints:
```powershell
Invoke-RestMethod http://127.0.0.1:2402/readyz
Invoke-RestMethod http://127.0.0.1:2402/current4
```
Connect the main server:
```dotenv
TELESRV_UPDATE_PUBLIC_URL=https://updates.example.test
TELESRV_UPDATE_SERVICE_URL=http://127.0.0.1:2402
TELESRV_UPDATE_REQUEST_TIMEOUT=2s
```
`PUBLIC_URL` must be reachable by clients and is advertised as
`help.getConfig.autoupdate_url_prefix`. `SERVICE_URL` may remain a loopback or
private route. When both routes are identical, `SERVICE_URL` may be omitted.
Production deployments should place an HTTPS reverse proxy in front of the
service without rewriting `/current4` or `/files/*`.
Standalone service settings:
```dotenv
TELESRV_UPDATE_LISTEN=127.0.0.1:2402
TELESRV_UPDATE_MANIFEST=data/updates/manifest.json
TELESRV_UPDATE_FILES_DIR=data/updates/files
```
The manifest is reloaded automatically when its timestamp or size changes. A
malformed replacement makes readiness and catalog requests return `503`; it is
never combined with the previously validated snapshot. Validate a candidate
before atomically replacing the active file:
```powershell
go run ./cmd/telesrv-update `
-manifest .\manifest.next.json `
-files .\data\updates\files `
-check
```
## Telegram Desktop contract
TDesktop requests `<autoupdate_url_prefix>/current4`. A Windows x64 stable
release is represented as:
```json
{
"win64": {
"stable": {
"released": 7000007,
"link": "/files/tx64upd7000007"
}
}
}
```
The client compares `released` with its numeric `AppVersion`, downloads the
artifact with HTTP Range support, verifies the embedded RSA signature, unpacks
it, and only then exposes the normal update banner. A regular EXE or ZIP is not
a valid update package.
Build packages with TDesktop's `Packer` target. The equivalent Windows x64
command is:
```powershell
Packer.exe `
-version 7000007 `
-path Telegram.exe `
-path Updater.exe `
-path "modules\x64\d3d\d3dcompiler_47.dll" `
-target win64
```
It produces `tx64upd7000007` and must report `Signature verified!` before the
artifact is published.
| Platform | `/current4` key | Typical package name |
|---|---|---|
| Windows x64 | `win64` | `tx64upd<build>` |
| Windows ARM64 | `winarm` | `tarm64upd<build>` |
| Windows x86 | `win` | `tupdate<build>` |
| macOS Intel | `mac` | `tmacupd<build>` |
| macOS Apple Silicon | `armac` | `tarmacupd<build>` |
| Linux | `linux` | `tlinuxupd<build>` |
## Update signing
The public TDesktop sources contain Telegram's public update key; the matching
private key is not published. A custom deployment must establish its own update
signing identity:
1. Generate a dedicated RSA-1024 key pair and keep the private key only in a
protected build secret store.
2. Provide the private key to TDesktop's local `DesktopPrivate/packer_private.h`.
3. Embed the matching public key in both the client update verifier and Packer.
4. Rebuild the bootstrap client and Packer before publishing updates.
5. Do not rotate the key without a transition client that trusts both identities.
The update service never reads the private key and never creates signatures. It
checks SHA-256 and serves an artifact that Packer has already signed. A stock
TDesktop binary cannot install a package signed only by a custom key; the first
custom client build must be distributed out of band.
## HTTP behavior
- `/healthz` reports process liveness.
- `/readyz` validates the current catalog.
- `/current`, `/current1` ... `/current4` return desktop metadata with
`Cache-Control: no-cache`.
- `/files/<name>` serves only artifacts referenced by the current validated
catalog, supports GET/HEAD and Range, and emits an immutable cache policy and
a SHA-256-based ETag.
- `/v1/resolve` returns a newer application release or `204 No Content`.
Unknown package names are not exposed merely because a file exists in the
directory. Published package names are immutable: changing an active file makes
the endpoint return `503` until a matching manifest snapshot is loaded.
## Android and iOS
The main server forwards the client platform, current `app_version`, source,
channel, and `lang_code` to `/v1/resolve`. The resolver selects localized notes,
does not offer an equal or older version, and applies `url_by_source` when a
matching installer/store source is configured.
- A standalone Android build may open or install an APK URL, but the APK must be
signed with the same Android application signing key as the installed build.
- Google Play and other store builds should use the corresponding store URL;
this mechanism does not bypass store policy.
- iOS may display information returned by `help.getAppUpdate`, but installation
still happens through App Store, TestFlight, or MDM. A normal iOS application
cannot replace itself from an arbitrary IPA URL.
- Set `can_not_skip` only after confirming that the target release is actually
available to every affected client.
## Manifest fields
- `desktop.<platform>.<channel>.build`: numeric TDesktop `AppVersion`.
- `file`, `sha256`, `size`: immutable signed artifact and integrity metadata.
- `apps.<platform>.<channel>.id`: stable positive release identifier.
- `version`: value compared with the client's `initConnection.app_version`.
- `notes`: localized text keyed by `en`, `ru`, `ru-ru`, and similar codes.
- `url_by_source`: source-specific installer or store URL.
- `can_not_skip`: whether the client may dismiss the application update.
- `disabled`: keep a valid entry as a draft without publishing it.
Supported channels are `stable`, `beta`, and `alpha`. See
`deploy/update/manifest.example.json` for a complete disabled example.

3
go.mod
View file

@ -9,9 +9,10 @@ require (
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/gotd/ige v0.3.0
github.com/gotd/log/logzap v0.1.1
github.com/iamxvbaba/td v1.2.1
github.com/iamxvbaba/td v1.3.2
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa
github.com/jackc/pgx/v5 v5.9.2
github.com/klauspost/compress v1.19.1
github.com/lestrrat-go/jwx/v3 v3.1.1
github.com/minio/minio-go/v7 v7.2.1
github.com/pion/datachannel v1.6.2

8
go.sum
View file

@ -83,8 +83,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI=
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/iamxvbaba/td v1.2.1 h1:5+Ji1F/tdrN8zUxeeEbTPHBQGSnTDE+UAH+8pQi7O1Y=
github.com/iamxvbaba/td v1.2.1/go.mod h1:INkZJi18XbXtVOrldDnPtmQCJvIXhgDZdbo9MV/NY7M=
github.com/iamxvbaba/td v1.3.2 h1:/EwvDU0oiArAof16WDDGfUXg/w02m2MqzDskCnXakZA=
github.com/iamxvbaba/td v1.3.2/go.mod h1:INkZJi18XbXtVOrldDnPtmQCJvIXhgDZdbo9MV/NY7M=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@ -141,6 +141,8 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/nyaruka/phonenumbers v1.8.1 h1:2K9YMQuv1dCGqjjzB1DwmdCe89khT4KPBQb2CxAMMlU=
github.com/nyaruka/phonenumbers v1.8.1/go.mod h1:fsKPJ70O9JetEA4ggnJadYTFWwtGPvu/lETTXNXq6Cs=
github.com/ogen-go/ogen v1.23.0 h1:QaWeKm2KZ2zy7NkqqO1Vdl5idNqlG+svxdgwVAX+zbo=
github.com/ogen-go/ogen v1.23.0/go.mod h1:bwwvC3AmCV+LrL5lazyQwwof90402mdcSyI0FOzzpfM=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@ -274,6 +276,8 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View file

@ -0,0 +1,95 @@
package admin
import (
"context"
"strings"
"testing"
"telesrv/internal/domain"
)
type fakeGifCatalogService struct {
entries []domain.GifCatalogEntry
listCalls int
uploadCalls int
createCalls int
}
func (s *fakeGifCatalogService) ValidateGifUpload(_ string, data []byte) (string, bool) {
return "image/gif", strings.HasPrefix(string(data), "GIF89a")
}
func (s *fakeGifCatalogService) AdminUploadGifMaterial(context.Context, string, []byte) (domain.Document, error) {
s.uploadCalls++
return domain.Document{ID: 91}, nil
}
func (s *fakeGifCatalogService) AdminCreateGifCatalogEntry(context.Context, string, int64) (domain.GifCatalogEntry, error) {
s.createCalls++
entry := domain.GifCatalogEntry{ID: 92, DocumentID: 91, Title: "Wave", Enabled: true}
s.entries = append(s.entries, entry)
return entry, nil
}
func (s *fakeGifCatalogService) AdminListGifCatalog(context.Context) ([]domain.GifCatalogEntry, error) {
s.listCalls++
return append([]domain.GifCatalogEntry(nil), s.entries...), nil
}
func (*fakeGifCatalogService) AdminSetGifCatalogEnabled(context.Context, int64, bool) (bool, error) {
return true, nil
}
func (*fakeGifCatalogService) AdminSetGifCatalogSortOrder(context.Context, int64, int) (bool, error) {
return true, nil
}
func (*fakeGifCatalogService) AdminSetGifCatalogCategory(context.Context, int64, string) (bool, error) {
return true, nil
}
func (*fakeGifCatalogService) AdminAutoCategorizeGifCatalog(context.Context) (int, error) {
return 0, nil
}
func (*fakeGifCatalogService) AdminDeleteUncategorizedGifs(context.Context) (int, int, error) {
return 0, 0, nil
}
func (*fakeGifCatalogService) AdminDeleteGifCatalogEntry(context.Context, int64) (bool, error) {
return true, nil
}
func (*fakeGifCatalogService) GetFile(context.Context, domain.FileDownloadRequest) (domain.FileChunk, bool, error) {
return domain.FileChunk{}, false, nil
}
func TestCreateGifCatalogEntryReplayAndContentFingerprint(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
gifs := &fakeGifCatalogService{}
svc := NewService(Dependencies{Commands: repo, GifCatalog: gifs, Now: fixedNow})
req := CreateGifCatalogEntryRequest{
CommandMeta: CommandMeta{CommandID: "gif-create-1", Actor: "ops", Reason: "catalog"},
Title: "Wave",
FileName: "wave.gif",
Data: []byte("GIF89a-one"),
}
first, err := svc.CreateGifCatalogEntry(ctx, req)
if err != nil || first.Status != string(domain.AdminCommandCompleted) {
t.Fatalf("first create = %+v err=%v", first, err)
}
if gifs.listCalls != 1 || gifs.uploadCalls != 1 || gifs.createCalls != 1 {
t.Fatalf("first calls list/upload/create=%d/%d/%d", gifs.listCalls, gifs.uploadCalls, gifs.createCalls)
}
// Even when the catalog has become full, the same completed command must be
// replayed before capacity preflight and must not upload another blob.
gifs.entries = make([]domain.GifCatalogEntry, domain.MaxGifCatalogEntries)
replay, err := svc.CreateGifCatalogEntry(ctx, req)
if err != nil || !replay.AlreadyExecuted {
t.Fatalf("replay = %+v err=%v", replay, err)
}
if gifs.listCalls != 1 || gifs.uploadCalls != 1 || gifs.createCalls != 1 {
t.Fatalf("replay calls list/upload/create=%d/%d/%d", gifs.listCalls, gifs.uploadCalls, gifs.createCalls)
}
conflict := req
conflict.Data = []byte("GIF89a-two")
if _, err := svc.CreateGifCatalogEntry(ctx, conflict); err == nil || err.Error() != "COMMAND_ID_CONFLICT" {
t.Fatalf("different content with same command id err=%v", err)
}
if gifs.listCalls != 1 || gifs.uploadCalls != 1 || gifs.createCalls != 1 {
t.Fatalf("conflict mutated list/upload/create=%d/%d/%d", gifs.listCalls, gifs.uploadCalls, gifs.createCalls)
}
}

View file

@ -4,6 +4,8 @@ import (
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@ -207,10 +209,15 @@ type UsersService interface {
// AccountService carries the login-email factor (account_passwords table),
// a separate concern from UsersService's users-table fields.
type AccountService interface {
// ValidLoginEmail reports whether email is an acceptable login/signup
// email address.
ValidLoginEmail(email string) bool
// SetLoginEmail force-sets a user's login/signup email, no OTP required.
SetLoginEmail(ctx context.Context, userID int64, email string) error
// ClearLoginEmail removes the login email factor entirely.
ClearLoginEmail(ctx context.Context, userID int64) error
// LoginEmail returns a user's current login/signup email, if any.
LoginEmail(ctx context.Context, userID int64) (string, bool, error)
}
// BroadcastService creates and lists system broadcast campaigns (a message
@ -290,6 +297,11 @@ type StickerSetsService interface {
// ValidateStickerMaterialUpload is a pure check (no store writes) so a dry-run
// preview can validate an uploaded file's shape without materializing it.
ValidateStickerMaterialUpload(fileName string, data []byte) (mimeType string, ok bool)
// ValidateAdminCreateStickerSet and ValidateAdminAddStickerToSet are pure
// checks (no store writes), used by a dry-run preview before the
// corresponding Admin* call actually mutates the pack.
ValidateAdminCreateStickerSet(ctx context.Context, title, shortName, emoji string, kind domain.StickerSetKind) error
ValidateAdminAddStickerToSet(ctx context.Context, setID int64, emoji string) error
AdminUploadStickerMaterial(ctx context.Context, fileName string, data []byte) (domain.Document, error)
AdminCreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error)
AdminAddStickerToSet(ctx context.Context, setID int64, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error)
@ -695,9 +707,10 @@ type RemoveStickerFromSetRequest struct {
type CreateGifCatalogEntryRequest struct {
CommandMeta
Title string `json:"title"`
FileName string `json:"file_name"`
Data []byte `json:"-"`
Title string `json:"title"`
FileName string `json:"file_name"`
Data []byte `json:"-"`
ContentSHA256 string `json:"content_sha256,omitempty"`
}
type SetGifCatalogEnabledRequest struct {
@ -937,8 +950,9 @@ type TransferCollectibleUsernameRequest struct {
// permanently when Burn is set.
type RevokeCollectibleUsernameRequest struct {
CommandMeta
Username string `json:"username"`
Burn bool `json:"burn"`
Username string `json:"username"`
ExpectedOwnerUserID int64 `json:"expected_owner_user_id,string,omitempty"`
Burn bool `json:"burn"`
}
// DeleteCollectibleUsernameRequest erases a collectible asset entirely. Unlike a
@ -1365,11 +1379,14 @@ func (s *Service) SetProfile(ctx context.Context, req SetProfileRequest) (Comman
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if domain.IsSystemUserID(req.UserID) {
return CommandResult{}, fmt.Errorf("system user profile cannot be changed")
}
if s == nil || s.users == nil {
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
}
firstName := strings.TrimSpace(req.FirstName)
lastName := strings.TrimSpace(req.LastName)
req.FirstName = strings.TrimSpace(req.FirstName)
req.LastName = strings.TrimSpace(req.LastName)
return s.runCommand(ctx, req.CommandMeta, ActionSetProfile, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
u, found, err := s.users.AdminUser(ctx, req.UserID)
if err != nil {
@ -1379,18 +1396,21 @@ func (s *Service) SetProfile(ctx context.Context, req SetProfileRequest) (Comman
return CommandResult{}, domain.ErrUserNotFound
}
details := map[string]any{
"previous_first_name": u.FirstName, "previous_last_name": u.LastName,
"new_first_name": firstName, "new_last_name": lastName,
"previous_first_name": u.FirstName,
"previous_last_name": u.LastName,
"new_first_name": req.FirstName,
"new_last_name": req.LastName,
"would_change": u.FirstName != req.FirstName || u.LastName != req.LastName,
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
return CommandResult{Message: "profile update validated", Details: details}, nil
}
updated, err := s.users.UpdateProfile(ctx, req.UserID, domain.UserProfileUpdate{
FirstName: firstName, HasFirstName: true,
LastName: lastName, HasLastName: true,
FirstName: req.FirstName, HasFirstName: true,
LastName: req.LastName, HasLastName: true,
})
if err != nil {
return CommandResult{}, err
return CommandResult{Details: details}, err
}
if err := s.notifyUserChanged(ctx, updated); err != nil {
details["notify_error"] = err.Error()
@ -1409,7 +1429,10 @@ func (s *Service) SetPhone(ctx context.Context, req SetPhoneRequest) (CommandRes
if s == nil || s.users == nil {
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
}
phone := strings.TrimSpace(req.Phone)
req.Phone = domain.NormalizePhone(req.Phone)
if !domain.ValidPhone(req.Phone) {
return CommandResult{}, domain.ErrPhoneNumberInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionSetPhone, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
u, found, err := s.users.AdminUser(ctx, req.UserID)
if err != nil {
@ -1418,15 +1441,22 @@ func (s *Service) SetPhone(ctx context.Context, req SetPhoneRequest) (CommandRes
if !found {
return CommandResult{}, domain.ErrUserNotFound
}
details := map[string]any{"previous_phone": u.Phone, "new_phone": phone}
if u.Bot || domain.IsSystemUserID(u.ID) {
return CommandResult{}, domain.ErrPhoneChangeForbidden
}
details := map[string]any{
"previous_phone": u.Phone,
"new_phone": req.Phone,
"would_change": u.Phone != req.Phone,
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
return CommandResult{Message: "phone update validated", Details: details}, nil
}
updated, err := s.users.SetPhone(ctx, req.UserID, phone)
updated, err := s.users.SetPhone(ctx, req.UserID, req.Phone)
if err != nil {
return CommandResult{}, err
return CommandResult{Details: details}, err
}
details["updated_phone"] = updated.Phone
details["changed"] = u.Phone != updated.Phone
if err := s.notifyUserChanged(ctx, updated); err != nil {
details["notify_error"] = err.Error()
}
@ -1442,26 +1472,46 @@ func (s *Service) SetLoginEmail(ctx context.Context, req SetLoginEmailRequest) (
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.account == nil {
return CommandResult{}, fmt.Errorf("admin account dependency is not configured")
if s == nil || s.users == nil || s.account == nil {
return CommandResult{}, fmt.Errorf("admin account dependencies are not configured")
}
req.Email = strings.TrimSpace(req.Email)
if req.Email != "" && !s.account.ValidLoginEmail(req.Email) {
return CommandResult{}, domain.ErrEmailInvalid
}
email := strings.TrimSpace(req.Email)
return s.runCommand(ctx, req.CommandMeta, ActionSetLoginEmail, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"new_login_email": email}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
var err error
if email == "" {
err = s.account.ClearLoginEmail(ctx, req.UserID)
} else {
err = s.account.SetLoginEmail(ctx, req.UserID, email)
}
u, found, err := s.users.AdminUser(ctx, req.UserID)
if err != nil {
return CommandResult{}, err
}
if !found {
return CommandResult{}, domain.ErrUserNotFound
}
if u.Bot || domain.IsSystemUserID(u.ID) {
return CommandResult{}, domain.ErrEmailInvalid
}
previous, _, err := s.account.LoginEmail(ctx, req.UserID)
if err != nil {
return CommandResult{}, err
}
details := map[string]any{
"previous_login_email": previous,
"new_login_email": req.Email,
"would_change": !strings.EqualFold(previous, req.Email),
}
if req.DryRun {
return CommandResult{Message: "login email update validated", Details: details}, nil
}
if req.Email == "" {
err = s.account.ClearLoginEmail(ctx, req.UserID)
} else {
err = s.account.SetLoginEmail(ctx, req.UserID, req.Email)
}
if err != nil {
return CommandResult{Details: details}, err
}
message := "login email updated"
if email == "" {
if req.Email == "" {
message = "login email cleared"
}
return CommandResult{Message: message, Details: details}, nil
@ -1475,31 +1525,37 @@ func (s *Service) SetAccountAvatar(ctx context.Context, req SetAccountAvatarRequ
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.photos == nil {
return CommandResult{}, fmt.Errorf("admin photos dependency is not configured")
if domain.IsSystemUserID(req.UserID) {
return CommandResult{}, fmt.Errorf("system user avatar cannot be changed")
}
if s == nil || s.users == nil || s.photos == nil {
return CommandResult{}, fmt.Errorf("admin avatar dependencies are not configured")
}
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
return CommandResult{}, domain.ErrPhotoInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountAvatar, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"file_name": req.FileName, "bytes": len(req.Data)}
u, found, err := s.users.AdminUser(ctx, req.UserID)
if err != nil {
return CommandResult{}, err
}
if !found {
return CommandResult{}, domain.ErrUserNotFound
}
details := map[string]any{"file_name": req.FileName, "bytes": len(req.Data), "bot": u.Bot}
if req.DryRun {
return CommandResult{Message: "avatar validated", Details: details}, nil
return CommandResult{Message: "avatar update validated", Details: details}, nil
}
photo, err := s.photos.CreateAvatarFromBytes(ctx, req.Data, req.UserID)
if err != nil {
return CommandResult{Details: details}, err
}
if _, _, err := s.photos.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, req.UserID, domain.ProfilePhotoKindProfile, photo.ID, int(time.Now().Unix())); err != nil {
if _, _, err := s.photos.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, req.UserID, domain.ProfilePhotoKindProfile, photo.ID, int(s.now().Unix())); err != nil {
return CommandResult{Details: details}, err
}
details["photo_id"] = photo.ID
if s.users != nil {
if u, found, uerr := s.users.AdminUser(ctx, req.UserID); uerr == nil && found {
if nerr := s.notifyUserChanged(ctx, u); nerr != nil {
details["notify_error"] = nerr.Error()
}
}
details["photo_id"] = strconv.FormatInt(photo.ID, 10)
if err := s.notifyUserChanged(ctx, u); err != nil {
details["notify_error"] = err.Error()
}
return CommandResult{Message: "avatar updated", Details: details}, nil
})
@ -1514,20 +1570,28 @@ func (s *Service) SetChannelAvatar(ctx context.Context, req SetChannelAvatarRequ
if req.ChannelID <= 0 {
return CommandResult{}, fmt.Errorf("channel_id is required")
}
if s == nil || s.photos == nil {
return CommandResult{}, fmt.Errorf("admin photos dependency is not configured")
}
if s.channels == nil {
return CommandResult{}, fmt.Errorf("admin channel dependency is not configured")
if s == nil || s.channels == nil || s.photos == nil {
return CommandResult{}, fmt.Errorf("admin channel avatar dependencies are not configured")
}
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
return CommandResult{}, domain.ErrPhotoInvalid
}
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelAvatar, 0, target, req, func() (CommandResult, error) {
details := map[string]any{"file_name": req.FileName, "bytes": len(req.Data)}
channel, err := s.channels.GetChannelByID(ctx, req.ChannelID)
if err != nil {
return CommandResult{}, err
}
if channel.Deleted || channel.Monoforum || (!channel.Broadcast && !channel.Megagroup) {
return CommandResult{}, domain.ErrChannelInvalid
}
details := map[string]any{
"file_name": req.FileName,
"bytes": len(req.Data),
"previous_photo_id": strconv.FormatInt(channel.PhotoID, 10),
}
if req.DryRun {
return CommandResult{Message: "avatar validated", Details: details}, nil
return CommandResult{Message: "channel avatar update validated", Details: details}, nil
}
photo, err := s.photos.CreateAvatarFromBytes(ctx, req.Data, 0)
if err != nil {
@ -1537,7 +1601,7 @@ func (s *Service) SetChannelAvatar(ctx context.Context, req SetChannelAvatarRequ
if err != nil {
return CommandResult{Details: details}, err
}
details["photo_id"] = photo.ID
details["photo_id"] = strconv.FormatInt(photo.ID, 10)
if err := s.notifyChannelChanged(ctx, updated); err != nil {
details["notify_error"] = err.Error()
}
@ -1562,38 +1626,10 @@ func (s *Service) ChannelAvatar(ctx context.Context, channelID int64) ([]byte, s
return nil, "", false, nil
}
photo, found, err := s.photos.GetPhoto(ctx, channel.PhotoID)
if err != nil {
return nil, "", false, err
if err != nil || !found {
return nil, "", found, err
}
if !found {
return nil, "", false, nil
}
size, inline, ok := bestAccountPhotoSize(photo.Sizes)
if !ok {
return nil, "", false, nil
}
data := inline
if len(data) == 0 {
chunk, found, err := s.photos.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("photo:%d:%s", photo.ID, size.Type),
Limit: MaxAccountAvatarBytes + 1,
})
if err != nil {
return nil, "", false, err
}
if !found || chunk.Total <= 0 || chunk.Total > MaxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
return nil, "", false, nil
}
data = chunk.Bytes
}
if len(data) == 0 || len(data) > MaxAccountAvatarBytes {
return nil, "", false, nil
}
detected := http.DetectContentType(data)
if !safeAccountImageType(detected) {
return nil, "", false, nil
}
return data, detected, true, nil
return s.avatarBytes(ctx, photo)
}
// SetUserColor force-sets or clears a user's name/profile color.
@ -1970,6 +2006,9 @@ func (s *Service) RevokeCollectibleUsername(ctx context.Context, req RevokeColle
if !domain.ValidCollectibleUsername(req.Username) {
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
}
if req.ExpectedOwnerUserID < 0 {
return CommandResult{}, fmt.Errorf("expected_owner_user_id must not be negative")
}
return s.runCommand(ctx, req.CommandMeta, ActionRevokeCollectibleUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"username": req.Username, "burn": req.Burn}
asset, err := s.usernames.Collectible(ctx, req.Username)
@ -1986,6 +2025,9 @@ func (s *Service) RevokeCollectibleUsername(ctx context.Context, req RevokeColle
if !req.Burn && !asset.Owned() {
return CommandResult{Details: details}, codedError(CodeCollectibleNotOwned, domain.ErrCollectibleUsernameNotOwned)
}
if req.ExpectedOwnerUserID > 0 && asset.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.ExpectedOwnerUserID}) {
return CommandResult{Details: details}, codedError(CodeCollectibleNotOwned, domain.ErrCollectibleUsernameNotOwned)
}
if req.DryRun {
return CommandResult{Message: "collectible username revoke validated", Details: details}, nil
}
@ -2362,7 +2404,17 @@ func (s *Service) RevokeSessions(ctx context.Context, req RevokeSessionsRequest)
if s == nil || s.auth == nil || s.revoker == nil {
return CommandResult{}, fmt.Errorf("admin auth dependencies are not configured")
}
if (req.Hash == 0 && req.KeepHash == 0 && !req.RevokeAll) || (req.Hash != 0 && (req.KeepHash != 0 || req.RevokeAll)) {
modeCount := 0
if req.Hash != 0 {
modeCount++
}
if req.KeepHash != 0 {
modeCount++
}
if req.RevokeAll {
modeCount++
}
if modeCount != 1 {
return CommandResult{}, fmt.Errorf("choose one revoke mode")
}
return s.runCommand(ctx, req.CommandMeta, ActionRevokeSessions, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
@ -2377,7 +2429,7 @@ func (s *Service) RevokeSessions(ctx context.Context, req RevokeSessionsRequest)
details := map[string]any{
"target_hashes": authorizationHashes(targets),
"target_count": len(targets),
"keep_hash": keep.Hash,
"keep_hash": authorizationHashString(keep.Hash),
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
@ -2388,9 +2440,10 @@ func (s *Service) RevokeSessions(ctx context.Context, req RevokeSessionsRequest)
if err != nil {
return CommandResult{}, err
}
if found {
revoked = append(revoked, deleted)
if !found {
return CommandResult{}, fmt.Errorf("authorization hash not found")
}
revoked = append(revoked, deleted)
} else {
deleted, err := s.auth.ResetAuthorizations(ctx, req.UserID, keep.AuthKeyID)
if err != nil {
@ -2544,12 +2597,13 @@ func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, stri
return nil, "", false, nil
}
photo, found, err := s.photos.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, userID, domain.ProfilePhotoKindProfile)
if err != nil {
return nil, "", false, err
}
if !found {
return nil, "", false, nil
if err != nil || !found {
return nil, "", found, err
}
return s.avatarBytes(ctx, photo)
}
func (s *Service) avatarBytes(ctx context.Context, photo domain.Photo) ([]byte, string, bool, error) {
size, inline, ok := bestAccountPhotoSize(photo.Sizes)
if !ok {
return nil, "", false, nil
@ -2560,10 +2614,10 @@ func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, stri
LocationKey: fmt.Sprintf("photo:%d:%s", photo.ID, size.Type),
Limit: MaxAccountAvatarBytes + 1,
})
if err != nil {
return nil, "", false, err
if err != nil || !found {
return nil, "", found, err
}
if !found || chunk.Total <= 0 || chunk.Total > MaxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
if chunk.Total <= 0 || chunk.Total > MaxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
return nil, "", false, nil
}
data = chunk.Bytes
@ -2571,11 +2625,11 @@ func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, stri
if len(data) == 0 || len(data) > MaxAccountAvatarBytes {
return nil, "", false, nil
}
detected := http.DetectContentType(data)
if !safeAccountImageType(detected) {
mimeType := http.DetectContentType(data)
if !safeAccountImageType(mimeType) {
return nil, "", false, nil
}
return data, detected, true, nil
return data, mimeType, true, nil
}
func bestAccountPhotoSize(sizes []domain.PhotoSize) (domain.PhotoSize, []byte, bool) {
@ -2659,7 +2713,7 @@ func (s *Service) SetStickerSetArchived(ctx context.Context, req SetStickerSetAr
func (s *Service) SetStickerSetSortOrder(ctx context.Context, req SetStickerSetSortOrderRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil || req.SetID <= 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 {
return CommandResult{}, fmt.Errorf("valid sticker set and service are required")
return CommandResult{}, domain.ErrStickerSetInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionSetStickerSetSortOrder, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "sort_order": req.SortOrder}
@ -2674,7 +2728,7 @@ func (s *Service) SetStickerSetSortOrder(ctx context.Context, req SetStickerSetS
func (s *Service) RenameStickerSet(ctx context.Context, req RenameStickerSetRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil || req.SetID <= 0 || strings.TrimSpace(req.Title) == "" {
return CommandResult{}, fmt.Errorf("valid sticker set, title and service are required")
return CommandResult{}, domain.ErrStickerSetInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionRenameStickerSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "title": req.Title}
@ -2692,7 +2746,7 @@ func (s *Service) RenameStickerSet(ctx context.Context, req RenameStickerSetRequ
func (s *Service) DeleteStickerSet(ctx context.Context, req DeleteStickerSetRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil || req.SetID <= 0 {
return CommandResult{}, fmt.Errorf("valid sticker set and service are required")
return CommandResult{}, domain.ErrStickerSetInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionDeleteStickerSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10)}
@ -2710,7 +2764,7 @@ func (s *Service) DeleteStickerSet(ctx context.Context, req DeleteStickerSetRequ
func (s *Service) CreateStickerSet(ctx context.Context, req CreateStickerSetRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil {
return CommandResult{}, fmt.Errorf("sticker sets service is not configured")
return CommandResult{}, domain.ErrStickerSetInvalid
}
if strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.ShortName) == "" || strings.TrimSpace(req.Emoji) == "" {
return CommandResult{}, domain.ErrStickerSetFileInvalid
@ -2728,6 +2782,9 @@ func (s *Service) CreateStickerSet(ctx context.Context, req CreateStickerSetRequ
"title": req.Title, "short_name": req.ShortName, "kind": string(kind),
"file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
}
if err := s.stickerSets.ValidateAdminCreateStickerSet(ctx, req.Title, req.ShortName, req.Emoji, kind); err != nil {
return CommandResult{Details: details}, err
}
if req.DryRun {
return CommandResult{Message: "sticker pack validated", Details: details}, nil
}
@ -2757,7 +2814,7 @@ func (s *Service) CreateStickerSet(ctx context.Context, req CreateStickerSetRequ
func (s *Service) AddStickerToSet(ctx context.Context, req AddStickerToSetRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil || req.SetID <= 0 {
return CommandResult{}, fmt.Errorf("valid sticker set and service are required")
return CommandResult{}, domain.ErrStickerSetInvalid
}
if strings.TrimSpace(req.Emoji) == "" {
return CommandResult{}, domain.ErrStickerSetEmojiInvalid
@ -2771,6 +2828,12 @@ func (s *Service) AddStickerToSet(ctx context.Context, req AddStickerToSetReques
"set_id": strconv.FormatInt(req.SetID, 10), "emoji": req.Emoji,
"file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
}
// Validate the target and item before materializing a loose
// document/blob. Keeping this inside runCommand preserves replay of a
// previously completed command even if the pack has since changed.
if err := s.stickerSets.ValidateAdminAddStickerToSet(ctx, req.SetID, req.Emoji); err != nil {
return CommandResult{Details: details}, err
}
if req.DryRun {
return CommandResult{Message: "sticker upload validated", Details: details}, nil
}
@ -2795,7 +2858,7 @@ func (s *Service) AddStickerToSet(ctx context.Context, req AddStickerToSetReques
func (s *Service) RemoveStickerFromSet(ctx context.Context, req RemoveStickerFromSetRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil || req.SetID <= 0 || req.DocumentID <= 0 {
return CommandResult{}, fmt.Errorf("valid sticker set, document and service are required")
return CommandResult{}, domain.ErrStickerSetInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionRemoveStickerFromSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "document_id": strconv.FormatInt(req.DocumentID, 10)}
@ -2811,9 +2874,18 @@ func (s *Service) RemoveStickerFromSet(ctx context.Context, req RemoveStickerFro
})
}
// GifCatalog returns every GIF catalog entry for the admin console's
// management view.
func (s *Service) GifCatalog(ctx context.Context) ([]domain.GifCatalogEntry, error) {
if s == nil || s.gifCatalog == nil {
return nil, domain.ErrGifCatalogUnavailable
}
return s.gifCatalog.AdminListGifCatalog(ctx)
}
func (s *Service) CreateGifCatalogEntry(ctx context.Context, req CreateGifCatalogEntryRequest) (CommandResult, error) {
if s == nil || s.gifCatalog == nil {
return CommandResult{}, fmt.Errorf("gif catalog service is not configured")
return CommandResult{}, domain.ErrGifCatalogUnavailable
}
if strings.TrimSpace(req.Title) == "" {
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
@ -2822,10 +2894,19 @@ func (s *Service) CreateGifCatalogEntry(ctx context.Context, req CreateGifCatalo
if !ok {
return CommandResult{}, domain.ErrGifCatalogFileInvalid
}
digest := sha256.Sum256(req.Data)
req.ContentSHA256 = hex.EncodeToString(digest[:])
return s.runCommand(ctx, req.CommandMeta, ActionCreateGifCatalogEntry, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"title": req.Title, "file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
}
entries, err := s.gifCatalog.AdminListGifCatalog(ctx)
if err != nil {
return CommandResult{Details: details}, err
}
if len(entries) >= domain.MaxGifCatalogEntries {
return CommandResult{Details: details}, domain.ErrGifCatalogFull
}
if req.DryRun {
return CommandResult{Message: "gif catalog entry validated", Details: details}, nil
}
@ -2845,7 +2926,7 @@ func (s *Service) CreateGifCatalogEntry(ctx context.Context, req CreateGifCatalo
func (s *Service) SetGifCatalogEnabled(ctx context.Context, req SetGifCatalogEnabledRequest) (CommandResult, error) {
if s == nil || s.gifCatalog == nil || req.ID <= 0 {
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionSetGifCatalogEnabled, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"id": strconv.FormatInt(req.ID, 10), "enabled": req.Enabled}
@ -2860,7 +2941,7 @@ func (s *Service) SetGifCatalogEnabled(ctx context.Context, req SetGifCatalogEna
func (s *Service) SetGifCatalogSortOrder(ctx context.Context, req SetGifCatalogSortOrderRequest) (CommandResult, error) {
if s == nil || s.gifCatalog == nil || req.ID <= 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 {
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionSetGifCatalogSortOrder, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"id": strconv.FormatInt(req.ID, 10), "sort_order": req.SortOrder}
@ -2938,7 +3019,7 @@ func (s *Service) DeleteUncategorizedGifs(ctx context.Context, req DeleteUncateg
func (s *Service) DeleteGifCatalogEntry(ctx context.Context, req DeleteGifCatalogEntryRequest) (CommandResult, error) {
if s == nil || s.gifCatalog == nil || req.ID <= 0 {
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionDeleteGifCatalogEntry, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"id": strconv.FormatInt(req.ID, 10)}
@ -3201,7 +3282,7 @@ func revokeTargets(items []domain.Authorization, req RevokeSessionsRequest) ([]d
return []domain.Authorization{a}, domain.Authorization{}, nil
}
}
return nil, domain.Authorization{}, nil
return nil, domain.Authorization{}, fmt.Errorf("authorization hash not found")
}
var keep domain.Authorization
if req.KeepHash != 0 {
@ -3227,15 +3308,26 @@ func revokeTargets(items []domain.Authorization, req RevokeSessionsRequest) ([]d
return targets, keep, nil
}
func authorizationHashes(items []domain.Authorization) []int64 {
out := make([]int64, 0, len(items))
func authorizationHashes(items []domain.Authorization) []string {
hashes := make([]int64, 0, len(items))
for _, a := range items {
out = append(out, a.Hash)
hashes = append(hashes, a.Hash)
}
sort.Slice(hashes, func(i, j int) bool { return hashes[i] < hashes[j] })
out := make([]string, 0, len(hashes))
for _, hash := range hashes {
out = append(out, authorizationHashString(hash))
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func authorizationHashString(hash int64) string {
if hash == 0 {
return ""
}
return strconv.FormatInt(hash, 10)
}
func normalizeIDs(ids []int) ([]int, error) {
if len(ids) == 0 {
return nil, domain.ErrMessageIDInvalid

View file

@ -59,6 +59,12 @@ const (
// decide how much a third-party mark is worth. Handing out the queue is
// routine; handing out the ability to appoint verifiers is not.
PermissionBotVerificationManage = "botverification.manage"
// PermissionPremiumManage guards grants, revocations and refunds. It is kept
// separate from Stars grants because a Premium refund mutates both ledgers.
PermissionPremiumManage = "premium.manage"
// PermissionBotTokenRead is intentionally narrower than unrestricted admin
// access because it reveals a live credential.
PermissionBotTokenRead = "bots.token.read"
)
// CodeForbidden is the stable code for a permission failure, so the panel can

View file

@ -201,7 +201,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/bots/create", s.authenticated(s.handleCreateBot))
mux.HandleFunc("POST /v1/broadcasts/create", s.authenticated(s.handleCreateBroadcast))
mux.HandleFunc("POST /v1/bots/delete", s.authenticated(s.handleDeleteBot))
mux.HandleFunc("POST /v1/bots/export-token", s.authenticated(s.handleExportBotToken))
mux.HandleFunc("POST /v1/bots/export-token", s.authorized(PermissionBotTokenRead, s.handleExportBotToken))
mux.HandleFunc("POST /v1/messages/delete", s.authenticated(s.handleDeleteMessages))
mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory))
mux.HandleFunc("POST /v1/stickers/set-archived", s.authenticated(s.handleSetStickerSetArchived))
@ -390,35 +390,13 @@ func (s *Server) handleSetLoginEmail(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleSetAccountAvatar(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var req admin.SetAccountAvatarRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
if !s.decodeAvatarUpload(w, r, &req.FileName, &req.Data) {
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "avatar file is required")
if !decodeMultipartMetadata(w, r, &req) {
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarBytes {
writeError(w, http.StatusBadRequest, "avatar file is empty or too large")
return
}
req.FileName = header.Filename
req.Data = data
result, err := s.svc.SetAccountAvatar(r.Context(), req)
writeCommandResult(w, result, err)
}
@ -446,37 +424,54 @@ func (s *Server) handleChannelAvatar(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleSetChannelAvatar(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var req admin.SetChannelAvatarRequest
if !s.decodeAvatarUpload(w, r, &req.FileName, &req.Data) {
return
}
if !decodeMultipartMetadata(w, r, &req) {
return
}
result, err := s.svc.SetChannelAvatar(r.Context(), req)
writeCommandResult(w, result, err)
}
// decodeAvatarUpload parses a multipart avatar-upload form shared by the
// account and channel avatar endpoints, reading the uploaded file into data.
func (s *Server) decodeAvatarUpload(w http.ResponseWriter, r *http.Request, fileName *string, data *[]byte) bool {
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
return false
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var req admin.SetChannelAvatarRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "avatar file is required")
return
return false
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarBytes {
raw, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
if err != nil || len(raw) == 0 || int64(len(raw)) > admin.MaxAccountAvatarBytes {
writeError(w, http.StatusBadRequest, "avatar file is empty or too large")
return
return false
}
req.FileName = header.Filename
req.Data = data
result, err := s.svc.SetChannelAvatar(r.Context(), req)
writeCommandResult(w, result, err)
*fileName = header.Filename
*data = raw
return true
}
// decodeMultipartMetadata decodes the JSON "metadata" form field of a
// multipart upload into dst, rejecting unknown fields.
func decodeMultipartMetadata(w http.ResponseWriter, r *http.Request, dst any) bool {
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return false
}
return true
}
func (s *Server) handleSetUserColor(w http.ResponseWriter, r *http.Request) {
@ -634,7 +629,7 @@ func (s *Server) handleDeleteStickerSet(w http.ResponseWriter, r *http.Request)
func (s *Server) handleCreateStickerSet(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, domain.MaxStickerMaterialDocumentSize+(1<<20))
r.Body = http.MaxBytesReader(w, r.Body, domain.MaxStickerMaterialDocumentSize+(2<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
@ -668,7 +663,7 @@ func (s *Server) handleCreateStickerSet(w http.ResponseWriter, r *http.Request)
func (s *Server) handleAddStickerToSet(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, domain.MaxStickerMaterialDocumentSize+(1<<20))
r.Body = http.MaxBytesReader(w, r.Body, domain.MaxStickerMaterialDocumentSize+(2<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return

View file

@ -19,6 +19,22 @@ import (
const accountDeletionDelay = 7 * 24 * time.Hour
func (s *Service) RevenueWithdrawalPasswordState(ctx context.Context, userID int64) (domain.RevenueWithdrawalPasswordState, error) {
if s == nil || s.lifecycle == nil || userID == 0 {
return domain.RevenueWithdrawalPasswordState{}, fmt.Errorf("revenue withdrawal password state is unavailable")
}
snapshot, found, err := s.lifecycle.AccountDeletionSnapshot(ctx, userID)
if err != nil {
return domain.RevenueWithdrawalPasswordState{}, err
}
if !found {
return domain.RevenueWithdrawalPasswordState{}, domain.ErrUserNotFound
}
return domain.RevenueWithdrawalPasswordState{
HasPassword: snapshot.HasPassword, PasswordChangedAt: snapshot.PasswordUpdatedAt,
}, nil
}
// DeleteAccount implements the official 2FA deletion decision. A supplied and
// valid SRP proof always deletes immediately. Without a proof, an account whose
// password is older than seven days and which was active during the last seven
@ -345,17 +361,3 @@ func (s *Service) SweepDueAccountDeletions(ctx context.Context, now time.Time, l
}
return out, nil
}
func (s *Service) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) {
if s == nil || s.lifecycle == nil {
return nil, nil
}
return s.lifecycle.ClaimAccountDeletionNotifications(ctx, now, limit, lease)
}
func (s *Service) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error {
if s == nil || s.lifecycle == nil {
return nil
}
return s.lifecycle.CompleteAccountDeletionNotification(ctx, id, now)
}

View file

@ -141,9 +141,3 @@ func (f *fakeAccountLifecycleStore) CancelAccountDeletion(_ context.Context, use
func (*fakeAccountLifecycleStore) DueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionCandidate, error) {
return nil, nil
}
func (*fakeAccountLifecycleStore) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
return nil, nil
}
func (*fakeAccountLifecycleStore) CompleteAccountDeletionNotification(context.Context, int64, time.Time) error {
return nil
}

View file

@ -15,18 +15,6 @@ import (
"telesrv/internal/store"
)
type reliablePhoneChangeDispatcher interface {
UsesReliableDispatch() bool
}
func (s *Service) PhoneChangeUsesReliableDispatch() bool {
if s == nil {
return false
}
reporter, ok := s.phoneChanges.(reliablePhoneChangeDispatcher)
return ok && reporter.UsesReliableDispatch()
}
// SendChangePhoneCode 创建只允许当前 user + perm auth_key 消费的改号验证码。
// CodeStore 会按 purpose+user+auth_key+phone 原子轮换:同一作用域的新请求
// 立即使旧 hash 失效,避免 Android 返回重进页面时留下并行有效验证码。

View file

@ -147,7 +147,7 @@ func TestPhoneChangeScopesCodeAndPersistsDurableEvent(t *testing.T) {
if err != nil {
t.Fatalf("change phone after session reconnect: %v", err)
}
if !result.Changed || result.User.Phone != "15550012002" || result.Event.Type != domain.UpdateEventUserPhone || result.Event.Phone != "15550012002" || result.Event.Pts != 1 {
if !result.Changed || result.User.Phone != "15550012002" {
t.Fatalf("change result = %+v", result)
}
if got := f.changes.lastRequest().ExcludeAuthKeyID; got != rawAuthKeyID {
@ -160,7 +160,7 @@ func TestPhoneChangeScopesCodeAndPersistsDurableEvent(t *testing.T) {
t.Fatalf("new phone resolves to %+v found=%v", got, found)
}
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
if err != nil || len(events) != 1 || events[0].Type != domain.UpdateEventUserPhone || events[0].Phone != "15550012002" {
if err != nil || len(events) != 0 {
t.Fatalf("durable events = %+v err=%v", events, err)
}
if _, found, _ := f.codes.Get(f.ctx, hash); found {
@ -194,6 +194,26 @@ func TestPhoneChangeRejectsOccupiedAndCrossAuthCode(t *testing.T) {
}
}
func TestPhoneChangeRejectsOccupiedNationalTrunkVariant(t *testing.T) {
f := newPhoneChangeFixture(t)
occupied, err := f.users.Create(f.ctx, domain.User{AccessHash: 103, Phone: "989981679461", FirstName: "Iran"})
if err != nil {
t.Fatalf("create occupied user: %v", err)
}
if _, _, err := f.service.SendChangePhoneCode(
f.ctx,
f.user.ID,
f.authKeyID,
77,
"+98 0998 167 9461",
); !errors.Is(err, domain.ErrPhoneNumberOccupied) {
t.Fatalf("occupied trunk variant err = %v", err)
}
if got, found, err := f.users.ByPhone(f.ctx, "989981679461"); err != nil || !found || got.ID != occupied.ID {
t.Fatalf("canonical owner user=%+v found=%v err=%v", got, found, err)
}
}
func TestPhoneChangeWrongCodeExhaustsAttempts(t *testing.T) {
f := newPhoneChangeFixture(t)
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012005")
@ -233,12 +253,12 @@ func TestPhoneChangeNewSendInvalidatesPreviousHash(t *testing.T) {
t.Fatalf("new hash change: %v", err)
}
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
if err != nil || len(events) != 1 || events[0].Type != domain.UpdateEventUserPhone {
if err != nil || len(events) != 0 {
t.Fatalf("events = %+v err=%v", events, err)
}
}
func TestPhoneChangeConcurrentReplayAppendsOneEvent(t *testing.T) {
func TestPhoneChangeConcurrentReplayChangesOnceWithoutPTSEvent(t *testing.T) {
f := newPhoneChangeFixture(t)
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012007")
if err != nil {
@ -273,7 +293,7 @@ func TestPhoneChangeConcurrentReplayAppendsOneEvent(t *testing.T) {
t.Fatalf("successes=%d expired=%d", successes, expired)
}
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
if err != nil || len(events) != 1 || events[0].Pts != 1 {
if err != nil || len(events) != 0 {
t.Fatalf("events = %+v err=%v", events, err)
}
}

View file

@ -1053,6 +1053,12 @@ func (s *Service) SetLoginEmail(ctx context.Context, userID int64, email string)
return s.passwords.Save(ctx, userID, settings)
}
// ValidLoginEmail exposes the same normalization/shape gate to trusted
// administrative dry-runs without duplicating the address policy.
func (s *Service) ValidLoginEmail(email string) bool {
return validLoginEmail(normalizeLoginEmail(email))
}
// LoginEmail 返回已登录用户的登录邮箱原始地址(用于 verifyEmail 回显 emailVerified.email
func (s *Service) LoginEmail(ctx context.Context, userID int64) (string, bool, error) {
if s == nil || s.passwords == nil || userID == 0 {

View file

@ -73,6 +73,32 @@ func TestWebhookPhoneLoginUsesRandomSMSCode(t *testing.T) {
}
}
func TestWebhookPhoneLoginCanonicalizesNationalTrunkBeforeOTP(t *testing.T) {
ctx := context.Background()
sender := &captureOTPSender{}
svc := NewService(
memory.NewUserStore(),
memory.NewAuthorizationStore(),
memory.NewCodeStore(),
nil,
nil,
"fixed-code-must-not-leak",
WithPhoneCodeDelivery(sender, 6),
)
hash, err := svc.SendCode(ctx, "+98 0998 167 9461")
if err != nil {
t.Fatalf("SendCode: %v", err)
}
if len(sender.requests) != 1 || sender.requests[0].Recipient != "989981679461" {
t.Fatalf("OTP requests = %+v, want canonical Iran recipient", sender.requests)
}
_, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, "989981679461", hash, sender.requests[0].Code)
if err != nil || !needSignUp {
t.Fatalf("SignIn canonical variant needSignUp=%v err=%v", needSignUp, err)
}
}
func TestWebhookExistingAccountRejectionKeepsDurableAppCode(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()

View file

@ -64,7 +64,10 @@ func validPhone(phone string) bool {
}
func systemUserLoginForbidden(u domain.User) bool {
return domain.IsSystemUserID(u.ID)
// Login-facing callers deliberately use the same non-enumerating error for
// reserved identities and irreversible tombstones. The durable authorization
// store repeats the deleted check under the user lock to close the TOCTOU gap.
return u.Deleted || domain.IsSystemUserID(u.ID)
}
func systemLoginPhoneForbidden(phone string) bool {
@ -270,12 +273,14 @@ func NewService(users store.UserStore, auths store.AuthorizationStore, codes sto
}
// BindTempAuthKey 校验并记录 TDesktop PFS temp→perm auth key 绑定。
func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) error {
func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (domain.TempAuthKeyBindingResult, error) {
var validated domain.TempAuthKeyBindingResult
if s.authKeys != nil {
inner, protocolExpiresAt, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
inner, protocolExpiresAt, result, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
if err != nil {
return err
return domain.TempAuthKeyBindingResult{}, err
}
validated = result
binding.TempSessionID = inner.TempSessionID
// The bind request's expires_at is a signed client assertion. TDesktop
// intentionally adds a small grace interval, while Android derives its
@ -287,21 +292,22 @@ func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding
// The edge may admit the frame immediately before the temporary key's
// absolute boundary and the encrypted proof may cross it. This is a temp-key
// rotation condition, never a destructive permanent-key proof failure.
return ErrTempAuthKeyEmpty
return domain.TempAuthKeyBindingResult{}, ErrTempAuthKeyEmpty
}
if s.tempKeys == nil {
return nil
return validated, nil
}
if err := s.tempKeys.Save(ctx, binding); err != nil {
result, err := s.tempKeys.SaveWithState(ctx, binding)
if err != nil {
if errors.Is(err, store.ErrTempAuthKeyAlreadyBound) {
return ErrTempAuthKeyAlreadyBound
return domain.TempAuthKeyBindingResult{}, ErrTempAuthKeyAlreadyBound
}
if errors.Is(err, store.ErrAuthKeyBindingInvalid) {
return s.classifyBindingStoreInvalid(ctx, binding)
return domain.TempAuthKeyBindingResult{}, s.classifyBindingStoreInvalid(ctx, binding)
}
return err
return domain.TempAuthKeyBindingResult{}, err
}
return nil
return result, nil
}
// ResolveAuthKey 将已绑定的 temp auth_key 解析为对应 perm auth_key。
@ -323,7 +329,7 @@ func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byt
// UserID 返回 auth_key 当前绑定的用户。未登录、或两步验证未完成时 found=false。
func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) {
if s == nil || s.auths == nil {
if s == nil || s.auths == nil || s.users == nil {
return 0, false, nil
}
a, found, err := s.auths.ByAuthKey(ctx, authKeyID)
@ -334,7 +340,13 @@ func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, e
// 两步验证未完成:业务鉴权视为未登录,仅允许 auth.checkPassword 继续。
return 0, false, nil
}
if domain.IsSystemUserID(a.UserID) {
u, userFound, err := s.users.ByID(ctx, a.UserID)
if err != nil {
return 0, false, err
}
if !userFound || systemUserLoginForbidden(u) {
// A stale row can exist only after an interrupted/legacy path. Fail closed
// before it reaches the Router auth cache and retire it opportunistically.
_ = s.auths.Delete(ctx, authKeyID)
return 0, false, nil
}
@ -344,14 +356,18 @@ func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, e
// PendingPasswordUserID 返回处于"待两步验证"状态的 auth_key 对应的用户。
// UserID 对 password_pending 的 auth_key 返回未登录auth.checkPassword 借此仍能定位待验证用户。
func (s *Service) PendingPasswordUserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) {
if s == nil || s.auths == nil {
if s == nil || s.auths == nil || s.users == nil {
return 0, false, nil
}
a, found, err := s.auths.ByAuthKey(ctx, authKeyID)
if err != nil || !found || !a.PasswordPending {
return 0, false, err
}
if domain.IsSystemUserID(a.UserID) {
u, userFound, err := s.users.ByID(ctx, a.UserID)
if err != nil {
return 0, false, err
}
if !userFound || systemUserLoginForbidden(u) {
_ = s.auths.Delete(ctx, authKeyID)
return 0, false, nil
}
@ -359,13 +375,24 @@ func (s *Service) PendingPasswordUserID(ctx context.Context, authKeyID [8]byte)
}
// CompletePasswordSignIn 在两步验证通过后清除 password_pending使 auth_key 转为完全授权。
func (s *Service) CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte) error {
func (s *Service) CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte, expectedUserID int64) error {
if s == nil || s.auths == nil {
return nil
}
if err := s.auths.MarkPasswordPassed(ctx, authKeyID); err != nil {
if expectedUserID == 0 {
return store.ErrAuthorizationStateChanged
}
if err := s.auths.MarkPasswordPassed(ctx, authKeyID, expectedUserID); err != nil {
return err
}
// Revalidate the active account after the CAS promotion before Router caches
// or binds the session. Account deletion can still linearize immediately
// after the update and must leave the caller unauthorized.
if userID, found, err := s.UserID(ctx, authKeyID); err != nil {
return err
} else if !found || userID != expectedUserID {
return ErrSystemUserLoginForbidden
}
// This is where a 2FA account's sign-in actually finishes — finishSignIn
// deliberately skipped the welcome message while password_pending.
if a, found, err := s.auths.ByAuthKey(ctx, authKeyID); err == nil && found {
@ -1423,7 +1450,11 @@ func (s *Service) AuthKeyClientInfo(ctx context.Context, authKeyID [8]byte) (dom
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
return domain.AuthKeyClientInfo{}, false, nil
}
key, found, err := s.authKeys.Get(ctx, authKeyID)
// Client metadata is a read-only projection. The physical connection's
// first-frame Get and active-key heartbeat already own the durable orphan
// lease, so this path must not turn every init/profile read into another
// last_used_at write.
key, found, err := s.authKeys.Revalidate(ctx, authKeyID)
if err != nil || !found {
return domain.AuthKeyClientInfo{}, found, err
}
@ -1499,6 +1530,16 @@ func (s *Service) ResetAuthorizations(ctx context.Context, userID int64, keepAut
}
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {
if s == nil || s.users == nil || s.auths == nil || userID == 0 {
return ErrSystemUserLoginForbidden
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return err
}
if !found || systemUserLoginForbidden(u) {
return ErrSystemUserLoginForbidden
}
if s.authKeys != nil {
key, found, err := s.authKeys.Get(ctx, auth.AuthKeyID)
if err != nil {
@ -1519,6 +1560,9 @@ func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID in
if errors.Is(err, store.ErrAuthKeyNotPermanent) {
return ErrAuthKeyPermEmpty
}
if errors.Is(err, domain.ErrAccountDeleted) || errors.Is(err, domain.ErrUserNotFound) {
return ErrSystemUserLoginForbidden
}
return err
}
return nil
@ -1535,17 +1579,19 @@ func (s *Service) passwordNeeded(ctx context.Context, userID int64) (bool, error
return found && settings.HasPassword, nil
}
const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
func loginMessageTemplate() string {
return `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else.
If you didn't request this code by trying to log in on another device, simply ignore this message.`
}
func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code string) (domain.Message, error) {
if s.messages == nil || s.dialogs == nil {
return domain.Message{}, nil
}
body := fmt.Sprintf(loginMessageTpl, code)
body := fmt.Sprintf(loginMessageTemplate(), code)
codeOffset := len("Login code: ")
msg, err := s.messages.Create(ctx, domain.Message{
OwnerUserID: userID,
@ -1595,53 +1641,58 @@ func (s *Service) recordWelcomeMessage(ctx context.Context, u domain.User) {
})
}
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, int, error) {
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, int, domain.TempAuthKeyBindingResult, error) {
if binding.ExpiresAt <= int(time.Now().Unix()) {
return mtcrypto.BindAuthKeyInner{}, 0, ErrExpiresAtInvalid
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrExpiresAtInvalid
}
temp, found, err := s.authKeys.Get(ctx, binding.TempAuthKeyID)
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
pair, err := s.authKeys.LoadBindingKeys(ctx, binding.TempAuthKeyID, permID)
if err != nil {
return mtcrypto.BindAuthKeyInner{}, 0, err
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, err
}
// expires_at in auth.bindTempAuthKey is client-supplied and must only attest
// to a still-live binding. It may never create or reclassify a protocol key;
// the caller normalizes durable retention to this handshake-authoritative
// temp.ExpiresAt instead of trusting the client value.
if !found || temp.ExpiresAt <= int(time.Now().Unix()) {
return mtcrypto.BindAuthKeyInner{}, 0, ErrTempAuthKeyEmpty
if !pair.TemporaryFound || pair.Temporary.ExpiresAt <= int(time.Now().Unix()) {
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrTempAuthKeyEmpty
}
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
perm, found, err := s.authKeys.Get(ctx, permID)
if err != nil {
return mtcrypto.BindAuthKeyInner{}, 0, err
}
if !found || perm.ExpiresAt != 0 {
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
if !pair.PermanentFound || pair.Permanent.ExpiresAt != 0 {
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrEncryptedMessageInvalid
}
inner, err := decryptBindAuthKeyInner(perm, binding.EncryptedMessage)
inner, err := decryptBindAuthKeyInner(pair.Permanent, binding.EncryptedMessage)
if err != nil {
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrEncryptedMessageInvalid
}
if inner.Nonce != binding.Nonce ||
inner.TempAuthKeyID != authKeyIDInt64(binding.TempAuthKeyID) ||
inner.PermAuthKeyID != binding.PermAuthKeyID ||
inner.TempSessionID != sessionID ||
inner.ExpiresAt != binding.ExpiresAt {
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrEncryptedMessageInvalid
}
if temp.ExpiresAt <= int(time.Now().Unix()) {
return mtcrypto.BindAuthKeyInner{}, 0, ErrTempAuthKeyEmpty
if pair.Temporary.ExpiresAt <= int(time.Now().Unix()) {
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrTempAuthKeyEmpty
}
return inner, temp.ExpiresAt, nil
layer, observationID, err := store.MergeAuthKeyLayerObservations(
pair.Temporary.Layer, pair.Temporary.LayerObservationID,
pair.Permanent.Layer, pair.Permanent.LayerObservationID,
)
if err != nil {
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, err
}
return inner, pair.Temporary.ExpiresAt, domain.TempAuthKeyBindingResult{
Layer: layer, LayerObservationID: observationID,
}, nil
}
func (s *Service) classifyBindingStoreInvalid(ctx context.Context, binding domain.TempAuthKeyBinding) error {
if s == nil || s.authKeys == nil {
return ErrEncryptedMessageInvalid
}
temp, found, err := s.authKeys.Get(ctx, binding.TempAuthKeyID)
temp, found, err := s.authKeys.Revalidate(ctx, binding.TempAuthKeyID)
if err != nil {
return err
}

View file

@ -48,7 +48,7 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
t.Fatalf("encrypt bind message: %v", err)
}
err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
_, err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
TempAuthKeyID: tempKey.ID,
PermAuthKeyID: permKey.IntID(),
Nonce: nonce,
@ -59,7 +59,7 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
t.Fatalf("BindTempAuthKey valid message: %v", err)
}
err = svc.BindTempAuthKey(ctx, sessionID+1, domain.TempAuthKeyBinding{
_, err = svc.BindTempAuthKey(ctx, sessionID+1, domain.TempAuthKeyBinding{
TempAuthKeyID: tempKey.ID,
PermAuthKeyID: permKey.IntID(),
Nonce: nonce,
@ -89,7 +89,7 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
if err != nil {
t.Fatalf("encrypt extended bind message: %v", err)
}
err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
_, err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
TempAuthKeyID: tempKey.ID,
PermAuthKeyID: permKey.IntID(),
Nonce: nonce,
@ -120,17 +120,17 @@ func TestBindTempAuthKeyClassifiesExpiryWithoutDestroyingPermanentKey(t *testing
PermAuthKeyID: permKey.IntID(),
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
}
if err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrTempAuthKeyEmpty) {
if _, err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrTempAuthKeyEmpty) {
t.Fatalf("expired protocol temp key err = %v, want ErrTempAuthKeyEmpty", err)
}
request.TempAuthKeyID = testAuthKey(0x33).ID
if err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrTempAuthKeyEmpty) {
if _, err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrTempAuthKeyEmpty) {
t.Fatalf("missing protocol temp key err = %v, want ErrTempAuthKeyEmpty", err)
}
request.ExpiresAt = int(time.Now().Add(-time.Second).Unix())
if err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrExpiresAtInvalid) {
if _, err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrExpiresAtInvalid) {
t.Fatalf("expired request proof err = %v, want ErrExpiresAtInvalid", err)
}
}
@ -346,6 +346,59 @@ func TestAuthorizationBindRejectsTemporaryProtocolKey(t *testing.T) {
}
}
func TestDeletedUserCannotCrossAuthorizationBoundaries(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
authz := memory.NewAuthorizationStore()
deleted, err := users.Create(ctx, domain.User{
Deleted: true,
DeletedAt: time.Now().Unix(),
DeletionSource: domain.AccountDeletionManual,
})
if err != nil {
t.Fatalf("create deleted user: %v", err)
}
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345")
passkeyAuthKeyID := [8]byte{0x91}
if _, err := svc.BindVerifiedLogin(ctx, domain.Authorization{AuthKeyID: passkeyAuthKeyID}, deleted.ID); !errors.Is(err, ErrSystemUserLoginForbidden) {
t.Fatalf("BindVerifiedLogin deleted user err = %v, want ErrSystemUserLoginForbidden", err)
}
if _, found, err := authz.ByAuthKey(ctx, passkeyAuthKeyID); err != nil || found {
t.Fatalf("deleted passkey authorization found=%v err=%v, want absent", found, err)
}
qrAuthKeyID := [8]byte{0x92}
if _, err := svc.AcceptLoginToken(ctx, domain.Authorization{AuthKeyID: qrAuthKeyID}, deleted.ID); !errors.Is(err, ErrSystemUserLoginForbidden) {
t.Fatalf("AcceptLoginToken deleted user err = %v, want ErrSystemUserLoginForbidden", err)
}
if _, found, err := authz.ByAuthKey(ctx, qrAuthKeyID); err != nil || found {
t.Fatalf("deleted QR authorization found=%v err=%v, want absent", found, err)
}
staleAuthKeyID := [8]byte{0x93}
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: staleAuthKeyID, UserID: deleted.ID}); err != nil {
t.Fatalf("seed stale authorization: %v", err)
}
if userID, found, err := svc.UserID(ctx, staleAuthKeyID); err != nil || found || userID != 0 {
t.Fatalf("UserID stale tombstone = %d found=%v err=%v, want unauthorized", userID, found, err)
}
if _, found, err := authz.ByAuthKey(ctx, staleAuthKeyID); err != nil || found {
t.Fatalf("stale tombstone authorization found=%v err=%v, want retired", found, err)
}
pendingAuthKeyID := [8]byte{0x94}
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: pendingAuthKeyID, UserID: deleted.ID, PasswordPending: true}); err != nil {
t.Fatalf("seed stale pending authorization: %v", err)
}
if err := svc.CompletePasswordSignIn(ctx, pendingAuthKeyID, deleted.ID); !errors.Is(err, ErrSystemUserLoginForbidden) {
t.Fatalf("CompletePasswordSignIn deleted user err = %v, want ErrSystemUserLoginForbidden", err)
}
if _, found, err := authz.ByAuthKey(ctx, pendingAuthKeyID); err != nil || found {
t.Fatalf("stale pending tombstone authorization found=%v err=%v, want retired", found, err)
}
}
func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
@ -377,6 +430,100 @@ func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) {
}
}
func TestVirtual888PhoneRegistersAndSignsIn(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
authz := memory.NewAuthorizationStore()
delivery := &captureLoginCodeDelivery{}
svc := NewService(
users,
authz,
memory.NewCodeStore(),
nil,
nil,
"12345",
WithLoginCodeDelivery(delivery),
)
const (
formatted = "+888 12-34"
canonical = "8881234"
)
firstHash, err := svc.SendCode(ctx, formatted)
if err != nil {
t.Fatalf("SendCode virtual phone: %v", err)
}
verifyCodeForSignUp(t, svc, canonical, firstHash, "12345")
created, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: [8]byte{0x54}}, formatted, firstHash, "Virtual", "User")
if err != nil {
t.Fatalf("SignUp virtual phone: %v", err)
}
if created.Phone != canonical {
t.Fatalf("created phone = %q, want %q", created.Phone, canonical)
}
secondHash, err := svc.SendCode(ctx, canonical)
if err != nil {
t.Fatalf("SendCode existing virtual phone: %v", err)
}
signedIn, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: [8]byte{0x55}}, formatted, secondHash, "12345")
if err != nil {
t.Fatalf("SignIn virtual phone: %v", err)
}
if needSignUp || signedIn.ID != created.ID {
t.Fatalf("SignIn user=%d needSignUp=%v, want existing user %d", signedIn.ID, needSignUp, created.ID)
}
}
func TestIranNationalTrunkVariantsShareOneAccountIdentity(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
authz := memory.NewAuthorizationStore()
delivery := &captureLoginCodeDelivery{}
svc := NewService(
users,
authz,
memory.NewCodeStore(),
nil,
nil,
"12345",
WithLoginCodeDelivery(delivery),
)
const (
withNationalTrunk = "+98 0998 167 9461"
international = "989981679461"
canonical = "989981679461"
)
firstHash, err := svc.SendCode(ctx, withNationalTrunk)
if err != nil {
t.Fatalf("SendCode trunk variant: %v", err)
}
verifyCodeForSignUp(t, svc, international, firstHash, "12345")
created, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: [8]byte{1}}, international, firstHash, "Iran", "User")
if err != nil {
t.Fatalf("SignUp international variant: %v", err)
}
if created.Phone != canonical {
t.Fatalf("created phone = %q, want %q", created.Phone, canonical)
}
secondHash, err := svc.SendCode(ctx, international)
if err != nil {
t.Fatalf("SendCode existing international variant: %v", err)
}
signedIn, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: [8]byte{2}}, withNationalTrunk, secondHash, "12345")
if err != nil {
t.Fatalf("SignIn trunk variant: %v", err)
}
if needSignUp || signedIn.ID != created.ID {
t.Fatalf("SignIn user=%d needSignUp=%v, want existing user %d", signedIn.ID, needSignUp, created.ID)
}
if got, found, err := users.ByPhone(ctx, canonical); err != nil || !found || got.ID != created.ID {
t.Fatalf("canonical lookup user=%+v found=%v err=%v", got, found, err)
}
}
func verifyCodeForSignUp(t *testing.T, svc *Service, phone, hash, code string) {
t.Helper()
got, msg, needSignUp, err := svc.SignIn(context.Background(), domain.Authorization{}, phone, hash, code)
@ -809,7 +956,10 @@ func TestSignInExistingTwoFactorAccountNeedsPassword(t *testing.T) {
t.Fatalf("PendingPasswordUserID = %d pending=%v err=%v, want %d", pendingUID, pending, err, u.ID)
}
// 两步验证通过后转为完全授权。
if err := svc.CompletePasswordSignIn(ctx, key); err != nil {
if err := svc.CompletePasswordSignIn(ctx, key, 0); !errors.Is(err, store.ErrAuthorizationStateChanged) {
t.Fatalf("CompletePasswordSignIn without expected user err=%v, want authorization state changed", err)
}
if err := svc.CompletePasswordSignIn(ctx, key, u.ID); err != nil {
t.Fatalf("CompletePasswordSignIn: %v", err)
}
bound, found, err = svc.UserID(ctx, key)

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