merged from gramsrv upstream
This commit is contained in:
parent
79c64ee916
commit
21a0856587
651 changed files with 54774 additions and 4590 deletions
42
.dockerignore
Normal file
42
.dockerignore
Normal 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
|
||||||
|
|
@ -317,7 +317,7 @@ TELESRV_MTPROTO_RPC_MAX_INFLIGHT=32
|
||||||
TELESRV_MTPROTO_RPC_QUEUE_SIZE=64
|
TELESRV_MTPROTO_RPC_QUEUE_SIZE=64
|
||||||
TELESRV_MTPROTO_RPC_TIMEOUT=30s
|
TELESRV_MTPROTO_RPC_TIMEOUT=30s
|
||||||
TELESRV_MTPROTO_RPC_GLOBAL_WORKERS=256
|
TELESRV_MTPROTO_RPC_GLOBAL_WORKERS=256
|
||||||
TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=8192
|
TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=32768
|
||||||
TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES=536870912
|
TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES=536870912
|
||||||
# Metadata-only rpc_result receipt budgets: global >= auth >= session. ACK deletes immediately;
|
# Metadata-only rpc_result receipt budgets: global >= auth >= session. ACK deletes immediately;
|
||||||
# 331s is only the no-ACK horizon. Payloads live solely in the logical-session outbound budget.
|
# 331s is only the no-ACK horizon. Payloads live solely in the logical-session outbound budget.
|
||||||
|
|
@ -474,6 +474,10 @@ TELESRV_VERIFICATION_BOT_RATE_WINDOW=1m
|
||||||
# rows. Interval must be positive; batch must be 1..500.
|
# rows. Interval must be positive; batch must be 1..500.
|
||||||
TELESRV_VERIFICATION_NOTIFY_INTERVAL=15s
|
TELESRV_VERIFICATION_NOTIFY_INTERVAL=15s
|
||||||
TELESRV_VERIFICATION_NOTIFY_BATCH=50
|
TELESRV_VERIFICATION_NOTIFY_BATCH=50
|
||||||
|
TELESRV_BROADCAST_WORKER_INTERVAL=3s
|
||||||
|
TELESRV_BROADCAST_WORKER_LEASE=30s
|
||||||
|
TELESRV_BROADCAST_MATERIALIZE_BATCH=200
|
||||||
|
TELESRV_BROADCAST_DELIVERY_BATCH=50
|
||||||
# Applications one applicant may keep open at once; 0 disables the cap, maximum
|
# Applications one applicant may keep open at once; 0 disables the cap, maximum
|
||||||
# is 50.
|
# is 50.
|
||||||
TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER=3
|
TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER=3
|
||||||
|
|
|
||||||
130
.github/workflows/build.yml
vendored
Normal file
130
.github/workflows/build.yml
vendored
Normal 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
180
.github/workflows/ci.yml
vendored
Normal 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
76
.github/workflows/container-images.yml
vendored
Normal 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
91
Dockerfile
Normal 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"]
|
||||||
|
|
@ -64,7 +64,7 @@ func run() error {
|
||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
hs := hoststats.NewPoller(cfg.BlobDir)
|
hs := hoststats.NewPoller(cfg.DiskStatsPath)
|
||||||
go hs.Run(ctx, hostStatsPollInterval)
|
go hs.Run(ctx, hostStatsPollInterval)
|
||||||
|
|
||||||
srv, err := newServer(cfg, newReadStore(pool), hs)
|
srv, err := newServer(cfg, newReadStore(pool), hs)
|
||||||
|
|
@ -97,10 +97,10 @@ type uiConfig struct {
|
||||||
Password string
|
Password string
|
||||||
Token string
|
Token string
|
||||||
SessionKey []byte
|
SessionKey []byte
|
||||||
// BlobDir is the local blob-storage root, reused only to pick which
|
// DiskStatsPath points the dashboard host-disk sampler at the local path
|
||||||
// filesystem the dashboard's disk-free reading statfs's -- irrelevant when
|
// that matters for the selected blob backend: permanent localfs storage or
|
||||||
// TELESRV_BLOB_BACKEND=s3, where disk space isn't the storage constraint.
|
// the S3 upload spool.
|
||||||
BlobDir string
|
DiskStatsPath string
|
||||||
// Permissions is the right set a panel session is issued with, from
|
// Permissions is the right set a panel session is issued with, from
|
||||||
// TELESRV_ADMIN_UI_PERMISSIONS. The shipped default is the single wildcard
|
// TELESRV_ADMIN_UI_PERMISSIONS. The shipped default is the single wildcard
|
||||||
// entry, so introducing the permission model never locks an operator out of a
|
// entry, so introducing the permission model never locks an operator out of a
|
||||||
|
|
@ -163,14 +163,21 @@ func loadConfig() (uiConfig, error) {
|
||||||
Password: appCfg.AdminUIPassword,
|
Password: appCfg.AdminUIPassword,
|
||||||
Token: appCfg.AdminUIToken,
|
Token: appCfg.AdminUIToken,
|
||||||
SessionKey: sum[:],
|
SessionKey: sum[:],
|
||||||
|
DiskStatsPath: dashboardDiskPath(appCfg),
|
||||||
Permissions: appCfg.AdminUIPermissions,
|
Permissions: appCfg.AdminUIPermissions,
|
||||||
HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
|
HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
|
||||||
BlobDir: appCfg.BlobDir,
|
|
||||||
IdentityDir: appCfg.IdentityDir,
|
IdentityDir: appCfg.IdentityDir,
|
||||||
RepoRoot: repoRoot,
|
RepoRoot: repoRoot,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dashboardDiskPath(cfg config.Config) string {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(cfg.BlobBackendKind), "s3") && strings.TrimSpace(cfg.BlobStagingDir) != "" {
|
||||||
|
return cfg.BlobStagingDir
|
||||||
|
}
|
||||||
|
return cfg.BlobDir
|
||||||
|
}
|
||||||
|
|
||||||
func adminAPIURL(addr string) string {
|
func adminAPIURL(addr string) string {
|
||||||
addr = strings.TrimSpace(addr)
|
addr = strings.TrimSpace(addr)
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ import (
|
||||||
// TELESRV_ADMIN_UI_PERMISSIONS and the ones the admin API enforces.
|
// TELESRV_ADMIN_UI_PERMISSIONS and the ones the admin API enforces.
|
||||||
const (
|
const (
|
||||||
permissionAll = "*"
|
permissionAll = "*"
|
||||||
|
permissionPremiumManage = "premium.manage"
|
||||||
|
permissionBotTokenRead = "bots.token.read"
|
||||||
permissionVerificationReview = "verification.review"
|
permissionVerificationReview = "verification.review"
|
||||||
permissionVerificationRevoke = "verification.revoke"
|
permissionVerificationRevoke = "verification.revoke"
|
||||||
// Third-party bot verification. Deliberately not implied by the official
|
// Third-party bot verification. Deliberately not implied by the official
|
||||||
|
|
|
||||||
|
|
@ -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-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/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/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/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/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
|
||||||
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
|
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,53 @@ func TestModerationReadAPIDisablesBrowserCaching(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthorizationRowJSONPreservesInt64AsDecimalStrings(t *testing.T) {
|
||||||
|
const maxInt64 = int64(9223372036854775807)
|
||||||
|
raw, err := json.Marshal(AuthorizationRow{AuthKeyID: maxInt64, Hash: maxInt64})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal authorization row: %v", err)
|
||||||
|
}
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &got); err != nil {
|
||||||
|
t.Fatalf("unmarshal authorization row: %v", err)
|
||||||
|
}
|
||||||
|
for _, field := range []string{"AuthKeyID", "Hash"} {
|
||||||
|
if got[field] != "9223372036854775807" {
|
||||||
|
t.Fatalf("authorization %s = %#v, want exact decimal string", field, got[field])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeSessionsBFFForwardsExactAuthorizationHash(t *testing.T) {
|
||||||
|
const authorizationHash = int64(2361577175213625973)
|
||||||
|
var got admin.RevokeSessionsRequest
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/accounts/revoke-sessions" || r.Header.Get("Authorization") != "Bearer secret" {
|
||||||
|
t.Fatalf("upstream request path=%q authorization=%q", r.URL.Path, r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed", DryRun: got.DryRun})
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/actions/revoke-sessions", strings.NewReader(`{
|
||||||
|
"reason":"precision regression","confirm":false,"user_id":1001,
|
||||||
|
"hash":"2361577175213625973"
|
||||||
|
}`))
|
||||||
|
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.handleRevokeSessionsAPI(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got.Hash != authorizationHash || got.Actor != "operator" || !got.DryRun {
|
||||||
|
t.Fatalf("forwarded revoke request = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMintCollectibleUsernameBFFForwardsActorAndTolerantScalars(t *testing.T) {
|
func TestMintCollectibleUsernameBFFForwardsActorAndTolerantScalars(t *testing.T) {
|
||||||
const maxInt64 = int64(9223372036854775807)
|
const maxInt64 = int64(9223372036854775807)
|
||||||
var got admin.MintCollectibleUsernameRequest
|
var got admin.MintCollectibleUsernameRequest
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-D8u51wND.js"></script>
|
<script type="module" crossorigin src="/assets/index-CwTwvGWj.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-hA2EpjuH.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-0MvM-hpw.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
6
cmd/telesrv-admin/web/package-lock.json
generated
6
cmd/telesrv-admin/web/package-lock.json
generated
|
|
@ -758,9 +758,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.16",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -193,3 +193,20 @@ export function parseIDs(value: string, invalidMessage = "msg ids invalid"): num
|
||||||
}
|
}
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toUnixSeconds reads a datetime-local input. Such an input carries no zone, so
|
||||||
|
// the value parses as the operator's local time — which is the time they picked.
|
||||||
|
// 0 means "empty or unparseable", which every caller treats as "not scheduled".
|
||||||
|
export function toUnixSeconds(value: string): number {
|
||||||
|
if (!value.trim()) return 0;
|
||||||
|
const ms = new Date(value).getTime();
|
||||||
|
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// localInputValue formats a datetime-local default some seconds out, so a
|
||||||
|
// scheduling form never opens on a value the server would reject as past.
|
||||||
|
export function localInputValue(offsetSeconds: number): string {
|
||||||
|
const at = new Date(Date.now() + offsetSeconds * 1000);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}:${pad(at.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ export function ownerLabel(row: CollectibleUsernameRow, vaultLabel: string): str
|
||||||
export function priceLabel(row: CollectibleUsernameRow): string {
|
export function priceLabel(row: CollectibleUsernameRow): string {
|
||||||
const base = formatCurrency(row.Amount, row.Currency);
|
const base = formatCurrency(row.Amount, row.Currency);
|
||||||
if (row.CryptoCurrency && row.CryptoAmount && row.CryptoAmount !== "0") {
|
if (row.CryptoCurrency && row.CryptoAmount && row.CryptoAmount !== "0") {
|
||||||
return `${base} (${formatCurrency(row.CryptoAmount, row.CryptoCurrency)})`;
|
return `${formatCurrency(row.CryptoAmount, row.CryptoCurrency)} (${base})`;
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { Alert, PageFrame } from "./components/ui";
|
||||||
// (cmd/telesrv-admin/security.go). "*" is the wildcard an operator configures for
|
// (cmd/telesrv-admin/security.go). "*" is the wildcard an operator configures for
|
||||||
// a full-access session.
|
// a full-access session.
|
||||||
export const permissionAll = "*";
|
export const permissionAll = "*";
|
||||||
|
export const permissionPremiumManage = "premium.manage";
|
||||||
|
export const permissionBotTokenRead = "bots.token.read";
|
||||||
export const permissionVerificationReview = "verification.review";
|
export const permissionVerificationReview = "verification.review";
|
||||||
export const permissionVerificationRevoke = "verification.revoke";
|
export const permissionVerificationRevoke = "verification.revoke";
|
||||||
// Third-party verification is a separate mechanism and therefore a separate pair of
|
// Third-party verification is a separate mechanism and therefore a separate pair of
|
||||||
|
|
|
||||||
|
|
@ -425,3 +425,34 @@
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.secret-reveal {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--warn-tint);
|
||||||
|
border: 1px solid var(--warn-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-reveal-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--warn);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-reveal-row { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.secret-reveal-value {
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
letter-spacing: .12em;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,16 @@ func run(ctx context.Context, args []string) error {
|
||||||
return runKeygen(args[1:])
|
return runKeygen(args[1:])
|
||||||
case "provision":
|
case "provision":
|
||||||
return runProvision(ctx, args[1:])
|
return runProvision(ctx, args[1:])
|
||||||
|
case "plan-dataset":
|
||||||
|
return runPlanDataset(args[1:])
|
||||||
|
case "seed":
|
||||||
|
return runSeed(ctx, args[1:])
|
||||||
|
case "snapshot":
|
||||||
|
return runSnapshot(ctx, args[1:])
|
||||||
|
case "mutate-offline":
|
||||||
|
return runMutateOffline(ctx, args[1:])
|
||||||
|
case "startup-run":
|
||||||
|
return runStartup(ctx, args[1:])
|
||||||
case "run":
|
case "run":
|
||||||
return runLoad(ctx, args[1:])
|
return runLoad(ctx, args[1:])
|
||||||
case "summarize":
|
case "summarize":
|
||||||
|
|
@ -50,6 +60,231 @@ func run(ctx context.Context, args []string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runPlanDataset(args []string) error {
|
||||||
|
flags := flag.NewFlagSet("plan-dataset", flag.ContinueOnError)
|
||||||
|
out := flags.String("out", filepath.FromSlash("data/loadtest/dataset.json"), "owner-only immutable dataset plan")
|
||||||
|
accounts := flags.Int("accounts", 1000, "logical primary accounts in the provisioned manifest")
|
||||||
|
seed := flags.Int64("seed", 20260827, "deterministic topology and idempotency seed")
|
||||||
|
privateFanout := flags.Int("private-fanout", -1, "outgoing private messages per account; -1 uses min(10, accounts-1)")
|
||||||
|
hotGroups := flags.Int("hot-groups", 10, "hot supergroup count")
|
||||||
|
hotMembers := flags.Int("hot-members", 0, "members per hot supergroup; 0 uses all accounts")
|
||||||
|
hotHistory := flags.Int("hot-history", 100, "messages per hot supergroup")
|
||||||
|
mediumGroups := flags.Int("medium-groups", 100, "medium supergroup count")
|
||||||
|
mediumMembers := flags.Int("medium-members", 100, "members per medium supergroup")
|
||||||
|
mediumHistory := flags.Int("medium-history", 30, "messages per medium supergroup")
|
||||||
|
smallGroups := flags.Int("small-groups", 200, "small supergroup count")
|
||||||
|
smallMembers := flags.Int("small-members", 20, "members per small supergroup")
|
||||||
|
smallHistory := flags.Int("small-history", 10, "messages per small supergroup")
|
||||||
|
heavyGroups := flags.Int("heavy-groups", 200, "heavy-user supergroup count")
|
||||||
|
heavyAccounts := flags.Int("heavy-accounts", 100, "accounts included in every heavy supergroup")
|
||||||
|
heavyHistory := flags.Int("heavy-history", 30, "messages per heavy supergroup")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flags.NArg() != 0 {
|
||||||
|
return errors.New("plan-dataset accepts no positional arguments")
|
||||||
|
}
|
||||||
|
if *hotMembers == 0 {
|
||||||
|
*hotMembers = *accounts
|
||||||
|
}
|
||||||
|
if *privateFanout == -1 {
|
||||||
|
*privateFanout = min(10, max(*accounts-1, 0))
|
||||||
|
}
|
||||||
|
cfg := loadharness.DatasetConfig{
|
||||||
|
Accounts: *accounts, Seed: *seed, PrivateFanout: *privateFanout,
|
||||||
|
HotGroups: *hotGroups, HotMembers: *hotMembers, HotHistory: *hotHistory,
|
||||||
|
MediumGroups: *mediumGroups, MediumMembers: min(*mediumMembers, *accounts), MediumHistory: *mediumHistory,
|
||||||
|
SmallGroups: *smallGroups, SmallMembers: min(*smallMembers, *accounts), SmallHistory: *smallHistory,
|
||||||
|
HeavyGroups: *heavyGroups, HeavyAccounts: min(*heavyAccounts, *accounts), HeavyHistory: *heavyHistory,
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(*out); err == nil {
|
||||||
|
existing, loadErr := loadharness.LoadDataset(*out)
|
||||||
|
if loadErr != nil {
|
||||||
|
return loadErr
|
||||||
|
}
|
||||||
|
if existing.Config != cfg {
|
||||||
|
return fmt.Errorf("refusing to replace existing dataset plan %s with different config", *out)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stdout, "dataset plan already exists at %s hash=%s groups=%d private_messages=%d\n",
|
||||||
|
*out, existing.PlanSHA256, len(existing.Groups), len(existing.PrivateEdges))
|
||||||
|
return nil
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dataset, err := loadharness.PlanDataset(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := loadharness.WriteDataset(*out, dataset); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stdout, "dataset plan written to %s hash=%s groups=%d private_messages=%d\n",
|
||||||
|
*out, dataset.PlanSHA256, len(dataset.Groups), len(dataset.PrivateEdges))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSeed(ctx context.Context, args []string) error {
|
||||||
|
flags := flag.NewFlagSet("seed", flag.ContinueOnError)
|
||||||
|
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
|
||||||
|
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
|
||||||
|
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
|
||||||
|
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
|
||||||
|
state := flags.String("state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "resumable seed journal")
|
||||||
|
concurrency := flags.Int("concurrency", 8, "parallel account workers (max 64)")
|
||||||
|
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one seed RPC")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flags.NArg() != 0 {
|
||||||
|
return errors.New("seed accepts no positional arguments")
|
||||||
|
}
|
||||||
|
result, err := loadharness.Seed(ctx, loadharness.SeedConfig{
|
||||||
|
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||||
|
DatasetPath: *dataset, SeedStatePath: *state, Concurrency: *concurrency, OperationTimeout: *operationTimeout,
|
||||||
|
}, func(event loadharness.SeedEvent) {
|
||||||
|
status := "ok"
|
||||||
|
if event.Err != nil {
|
||||||
|
status = "error"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stdout, "seed phase=%s %d/%d account=%d status=%s\n",
|
||||||
|
event.Phase, event.Completed, event.Total, event.Account, status)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stdout, "seed complete private_messages=%d supergroups=%d invited_members=%d group_messages=%d rich_state_accounts=%d state=%s\n",
|
||||||
|
result.PrivateMessages, result.Groups, result.InvitedMembers, result.GroupMessages, result.RichStateAccounts, *state)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSnapshot(ctx context.Context, args []string) error {
|
||||||
|
flags := flag.NewFlagSet("snapshot", flag.ContinueOnError)
|
||||||
|
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
|
||||||
|
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
|
||||||
|
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
|
||||||
|
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
|
||||||
|
seedState := flags.String("seed-state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "completed seed journal")
|
||||||
|
clientState := flags.String("client-state", filepath.FromSlash("data/loadtest/client-state.json"), "baseline account/dialog/PTS snapshot")
|
||||||
|
concurrency := flags.Int("concurrency", 8, "parallel account workers (max 64)")
|
||||||
|
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one snapshot RPC")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flags.NArg() != 0 {
|
||||||
|
return errors.New("snapshot accepts no positional arguments")
|
||||||
|
}
|
||||||
|
result, err := loadharness.SnapshotClientState(ctx, loadharness.SnapshotConfig{
|
||||||
|
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||||
|
DatasetPath: *dataset, SeedStatePath: *seedState, ClientStatePath: *clientState,
|
||||||
|
Concurrency: *concurrency, OperationTimeout: *operationTimeout,
|
||||||
|
}, func(event loadharness.SnapshotEvent) {
|
||||||
|
status := "ok"
|
||||||
|
if event.Resumed {
|
||||||
|
status = "resumed"
|
||||||
|
}
|
||||||
|
if event.Err != nil {
|
||||||
|
status = "error"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stdout, "snapshot %d/%d account=%d status=%s\n", event.Completed, event.Total, event.Account, status)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stdout, "snapshot complete accounts=%d dialogs=%d channel_dialogs=%d client_state=%s\n",
|
||||||
|
result.Accounts, result.Dialogs, result.Channels, *clientState)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMutateOffline(ctx context.Context, args []string) error {
|
||||||
|
flags := flag.NewFlagSet("mutate-offline", flag.ContinueOnError)
|
||||||
|
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
|
||||||
|
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
|
||||||
|
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
|
||||||
|
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
|
||||||
|
seedState := flags.String("seed-state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "completed seed journal")
|
||||||
|
clientState := flags.String("client-state", filepath.FromSlash("data/loadtest/client-state.json"), "immutable old account/channel PTS snapshot")
|
||||||
|
mutationState := flags.String("mutation-state", filepath.FromSlash("data/loadtest/offline-mutation-state.json"), "resumable offline mutation journal")
|
||||||
|
concurrency := flags.Int("concurrency", 8, "parallel writer accounts (max 64)")
|
||||||
|
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one mutation RPC")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flags.NArg() != 0 {
|
||||||
|
return errors.New("mutate-offline accepts no positional arguments")
|
||||||
|
}
|
||||||
|
result, err := loadharness.MutateOffline(ctx, loadharness.MutateOfflineConfig{
|
||||||
|
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||||
|
DatasetPath: *dataset, SeedStatePath: *seedState, ClientStatePath: *clientState,
|
||||||
|
MutationStatePath: *mutationState, Concurrency: *concurrency, OperationTimeout: *operationTimeout,
|
||||||
|
}, func(event loadharness.MutationEvent) {
|
||||||
|
status := "ok"
|
||||||
|
if event.Err != nil {
|
||||||
|
status = "error"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stdout, "mutate phase=%s %d/%d account=%d status=%s\n",
|
||||||
|
event.Phase, event.Completed, event.Total, event.Account, status)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stdout, "offline mutation complete private_messages=%d dirty_channels=%d channel_messages=%d edited=%d deleted=%d pinned=%d state=%s\n",
|
||||||
|
result.PrivateMessages, result.DirtyChannels, result.ChannelMessages, result.Edited, result.Deleted, result.Pinned, *mutationState)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStartup(ctx context.Context, args []string) error {
|
||||||
|
flags := flag.NewFlagSet("startup-run", flag.ContinueOnError)
|
||||||
|
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
|
||||||
|
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
|
||||||
|
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
|
||||||
|
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
|
||||||
|
seedState := flags.String("seed-state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "completed seed journal")
|
||||||
|
clientState := flags.String("client-state", filepath.FromSlash("data/loadtest/client-state.json"), "immutable old account/channel PTS snapshot")
|
||||||
|
mutationState := flags.String("mutation-state", filepath.FromSlash("data/loadtest/offline-mutation-state.json"), "completed offline mutation journal")
|
||||||
|
report := flags.String("report", filepath.FromSlash("data/loadtest/startup-report.json"), "startup correctness and latency report")
|
||||||
|
events := flags.String("events", filepath.FromSlash("data/loadtest/startup-events.ndjson"), "periodic owner-only startup and server metric evidence")
|
||||||
|
serverMetrics := flags.String("server-metrics", "http://127.0.0.1:6060/metrics", "server metrics URL; empty disables")
|
||||||
|
profile := flags.String("profile", loadharness.StartupProfileTDesktopReturningV1, "startup workload: tdesktop-cold-returning-v1 or tdlib-returning-v1")
|
||||||
|
startOrder := flags.String("start-order", loadharness.StartupOrderShuffled, "account launch order: shuffled or account-index")
|
||||||
|
startOrderSeed := flags.Int64("start-order-seed", 0, "deterministic shuffled launch seed; 0 uses the dataset seed")
|
||||||
|
accounts := flags.Int("accounts", 0, "limit first N accounts; 0 uses the complete dataset")
|
||||||
|
ramp := flags.Duration("ramp", 30*time.Second, "connection start ramp duration")
|
||||||
|
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one startup RPC")
|
||||||
|
sampleInterval := flags.Duration("sample-interval", 2*time.Second, "server resource sampling interval")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flags.NArg() != 0 {
|
||||||
|
return errors.New("startup-run accepts no positional arguments")
|
||||||
|
}
|
||||||
|
result, err := loadharness.StartupRun(ctx, loadharness.StartupRunConfig{
|
||||||
|
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||||
|
DatasetPath: *dataset, SeedStatePath: *seedState, ClientStatePath: *clientState,
|
||||||
|
MutationStatePath: *mutationState, ReportPath: *report, EventsPath: *events, ServerMetricsURL: *serverMetrics,
|
||||||
|
Profile: *profile, StartOrder: *startOrder, StartOrderSeed: *startOrderSeed,
|
||||||
|
AccountLimit: *accounts, RampDuration: *ramp, OperationTimeout: *operationTimeout,
|
||||||
|
SampleInterval: *sampleInterval,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printStartupSummary(result)
|
||||||
|
if !result.Pass {
|
||||||
|
return fmt.Errorf("startup acceptance failed; see %s", *report)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printStartupSummary(report *loadharness.StartupRunReport) {
|
||||||
|
fmt.Fprintf(os.Stdout, "pass=%v business_ready=%d/%d dialogs=%d channel_dialogs=%d account_diff_calls=%d channel_diff_calls=%d channel_full=%d channel_too_long=%d channel_empty=%d\n",
|
||||||
|
report.Pass, report.BusinessReady, report.ExpectedAccounts, report.DialogsObserved, report.ChannelDialogs,
|
||||||
|
report.AccountDifference.Calls, report.ChannelDifference.Calls, report.ChannelDifference.Full,
|
||||||
|
report.ChannelDifference.TooLong, report.ChannelDifference.Empty)
|
||||||
|
for _, failure := range report.Failures {
|
||||||
|
fmt.Fprintln(os.Stdout, "failure:", failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func runKeygen(args []string) error {
|
func runKeygen(args []string) error {
|
||||||
flags := flag.NewFlagSet("keygen", flag.ContinueOnError)
|
flags := flag.NewFlagSet("keygen", flag.ContinueOnError)
|
||||||
path := flags.String("out", filepath.FromSlash("data/loadtest/session.key"), "owner-only session encryption key file")
|
path := flags.String("out", filepath.FromSlash("data/loadtest/session.key"), "owner-only session encryption key file")
|
||||||
|
|
@ -78,7 +313,7 @@ func runProvision(ctx context.Context, args []string) error {
|
||||||
accounts := flags.Int("accounts", 450, "unique accounts")
|
accounts := flags.Int("accounts", 450, "unique accounts")
|
||||||
extraDevices := flags.Int("extra-devices", 50, "accounts receiving a second independent session")
|
extraDevices := flags.Int("extra-devices", 50, "accounts receiving a second independent session")
|
||||||
concurrency := flags.Int("concurrency", 8, "parallel provisioning workers (max 64)")
|
concurrency := flags.Int("concurrency", 8, "parallel provisioning workers (max 64)")
|
||||||
phonePrefix := flags.String("phone-prefix", "+155500", "E.164 prefix followed by a six-digit account index")
|
phonePrefix := flags.String("phone-prefix", loadharness.DefaultPhonePrefix, "possible reserved NANP prefix followed by a six-digit account index")
|
||||||
firstName := flags.String("first-name-prefix", "Load", "generated first-name prefix")
|
firstName := flags.String("first-name-prefix", "Load", "generated first-name prefix")
|
||||||
obfuscated := flags.Bool("obfuscated", true, "use TDesktop-like Obfuscated2 + abridged transport")
|
obfuscated := flags.Bool("obfuscated", true, "use TDesktop-like Obfuscated2 + abridged transport")
|
||||||
pfs := flags.Bool("pfs", true, "bind temporary auth keys using PFS")
|
pfs := flags.Bool("pfs", true, "bind temporary auth keys using PFS")
|
||||||
|
|
@ -129,12 +364,17 @@ func runLoad(ctx context.Context, args []string) error {
|
||||||
events := flags.String("events", filepath.FromSlash("data/loadtest/events.ndjson"), "periodic NDJSON evidence")
|
events := flags.String("events", filepath.FromSlash("data/loadtest/events.ndjson"), "periodic NDJSON evidence")
|
||||||
fileFixture := flags.String("file-fixture", "", "reusable fixture JSON; empty stores beside manifest")
|
fileFixture := flags.String("file-fixture", "", "reusable fixture JSON; empty stores beside manifest")
|
||||||
serverMetrics := flags.String("server-metrics", "http://127.0.0.1:6060/metrics", "server metrics URL; empty disables")
|
serverMetrics := flags.String("server-metrics", "http://127.0.0.1:6060/metrics", "server metrics URL; empty disables")
|
||||||
|
startOrder := flags.String("start-order", loadharness.StartupOrderShuffled, "session launch order: shuffled or account-index")
|
||||||
|
startOrderSeed := flags.Int64("start-order-seed", 20260827, "deterministic shuffled launch seed")
|
||||||
sessions := flags.Int("sessions", 0, "limit selected sessions; 0 uses all")
|
sessions := flags.Int("sessions", 0, "limit selected sessions; 0 uses all")
|
||||||
duration := flags.Duration("duration", 30*time.Minute, "sustained load duration")
|
duration := flags.Duration("duration", 30*time.Minute, "sustained load duration")
|
||||||
recovery := flags.Duration("recovery", 7*time.Minute, "post-disconnect reclamation observation")
|
recovery := flags.Duration("recovery", 7*time.Minute, "post-disconnect reclamation observation")
|
||||||
ramp := flags.Duration("ramp", 2*time.Minute, "connection ramp duration")
|
ramp := flags.Duration("ramp", 2*time.Minute, "connection ramp duration")
|
||||||
rpcInterval := flags.Duration("rpc-interval", 5*time.Second, "per-session background RPC interval")
|
rpcInterval := flags.Duration("rpc-interval", 5*time.Second, "per-session background RPC interval")
|
||||||
messageInterval := flags.Duration("message-interval", 30*time.Second, "per-primary-session message interval; negative disables")
|
messageInterval := flags.Duration("message-interval", 30*time.Second, "per-primary-session message interval; negative disables")
|
||||||
|
messageRate := flags.Float64("message-rate", 0, "aggregate fixed arrival rate in messages/second; use with message-interval=-1")
|
||||||
|
messageQueue := flags.Int("message-queue", 8, "bounded pending sends per primary session for fixed-rate workload")
|
||||||
|
deliverySettle := flags.Duration("delivery-settle", 10*time.Second, "maximum live-delivery settle time before final updates.getDifference reconciliation")
|
||||||
fileInterval := flags.Duration("file-interval", time.Minute, "per-session upload.getFile interval")
|
fileInterval := flags.Duration("file-interval", time.Minute, "per-session upload.getFile interval")
|
||||||
fileSize := flags.Int("file-size", 4<<20, "generated shared download fixture bytes; 0 disables")
|
fileSize := flags.Int("file-size", 4<<20, "generated shared download fixture bytes; 0 disables")
|
||||||
fileChunk := flags.Int("file-chunk", 1<<20, "upload.getFile bytes per request (max 1MiB)")
|
fileChunk := flags.Int("file-chunk", 1<<20, "upload.getFile bytes per request (max 1MiB)")
|
||||||
|
|
@ -155,8 +395,10 @@ func runLoad(ctx context.Context, args []string) error {
|
||||||
result, err := loadharness.Run(ctx, loadharness.RunConfig{
|
result, err := loadharness.Run(ctx, loadharness.RunConfig{
|
||||||
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||||
ReportPath: *report, EventsPath: *events, FileFixturePath: *fileFixture, ServerMetricsURL: *serverMetrics,
|
ReportPath: *report, EventsPath: *events, FileFixturePath: *fileFixture, ServerMetricsURL: *serverMetrics,
|
||||||
|
StartOrder: *startOrder, StartOrderSeed: *startOrderSeed,
|
||||||
SessionLimit: *sessions, Duration: *duration, RecoveryDuration: *recovery, RampDuration: *ramp,
|
SessionLimit: *sessions, Duration: *duration, RecoveryDuration: *recovery, RampDuration: *ramp,
|
||||||
RPCInterval: *rpcInterval, MessageInterval: *messageInterval, SampleInterval: *sampleInterval,
|
RPCInterval: *rpcInterval, MessageInterval: *messageInterval, MessageRate: *messageRate,
|
||||||
|
MessageQueueDepth: *messageQueue, DeliverySettle: *deliverySettle, SampleInterval: *sampleInterval,
|
||||||
FileInterval: *fileInterval, FileSizeBytes: *fileSize, FileChunkBytes: *fileChunk, SetupTimeout: *setupTimeout,
|
FileInterval: *fileInterval, FileSizeBytes: *fileSize, FileChunkBytes: *fileChunk, SetupTimeout: *setupTimeout,
|
||||||
OperationTimeout: *operationTimeout,
|
OperationTimeout: *operationTimeout,
|
||||||
OfflineFraction: *offlineFraction, OfflineAt: *offlineAt, OfflineFor: *offlineFor,
|
OfflineFraction: *offlineFraction, OfflineAt: *offlineAt, OfflineFor: *offlineFor,
|
||||||
|
|
@ -183,6 +425,25 @@ func runSummarize(args []string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
var shape struct {
|
||||||
|
BusinessReady *int `json:"business_ready"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &shape); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if shape.BusinessReady != nil {
|
||||||
|
var report loadharness.StartupRunReport
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&report); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printStartupSummary(&report)
|
||||||
|
if !report.Pass {
|
||||||
|
return errors.New("startup report did not pass")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
var report loadharness.RunReport
|
var report loadharness.RunReport
|
||||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
|
|
@ -197,9 +458,9 @@ func runSummarize(args []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func printSummary(report *loadharness.RunReport) {
|
func printSummary(report *loadharness.RunReport) {
|
||||||
fmt.Fprintf(os.Stdout, "pass=%v sessions=%d peak_ready=%d reconnects=%d disconnects=%d flood_waits=%d fatal_errors=%d\n",
|
fmt.Fprintf(os.Stdout, "pass=%v sessions=%d peak_ready=%d reconnects=%d disconnects=%d flood_waits=%d fatal_errors=%d scheduled=%d delivered=%d missing=%d\n",
|
||||||
report.Pass, report.ExpectedSessions, report.PeakReadySessions, report.Reconnects, report.Disconnects,
|
report.Pass, report.ExpectedSessions, report.PeakReadySessions, report.Reconnects, report.Disconnects,
|
||||||
totalFloodWaits(report), report.WorkerFatalErrors)
|
totalFloodWaits(report), report.WorkerFatalErrors, report.MessageScheduled, report.Delivery.Delivered, report.Delivery.Missing)
|
||||||
for _, failure := range report.Failures {
|
for _, failure := range report.Failures {
|
||||||
fmt.Fprintln(os.Stdout, "failure:", failure)
|
fmt.Fprintln(os.Stdout, "failure:", failure)
|
||||||
}
|
}
|
||||||
|
|
@ -214,12 +475,17 @@ func totalFloodWaits(report *loadharness.RunReport) uint64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
func usageError() error {
|
func usageError() error {
|
||||||
return errors.New("expected one of: keygen, provision, run, summarize, help")
|
return errors.New("expected one of: keygen, provision, plan-dataset, seed, snapshot, mutate-offline, startup-run, run, summarize, help")
|
||||||
}
|
}
|
||||||
|
|
||||||
const usageText = `telesrv-load commands:
|
const usageText = `telesrv-load commands:
|
||||||
keygen generate an owner-only AES-256 session key
|
keygen generate an owner-only AES-256 session key
|
||||||
provision create accounts and encrypted sessions through real MTProto auth
|
provision create accounts and encrypted sessions through real MTProto auth
|
||||||
|
plan-dataset create an immutable real-data topology with stable RPC identities
|
||||||
|
seed materialize private dialogs, supergroups and messages via real RPCs
|
||||||
|
snapshot save paginated real dialogs and old account/channel PTS cursors
|
||||||
|
mutate-offline create account/channel gaps while preserving the old cursors
|
||||||
|
startup-run restore old cursors and measure dialogs/difference business readiness
|
||||||
run execute sustained real-client load, offline recovery and reclamation
|
run execute sustained real-client load, offline recovery and reclamation
|
||||||
summarize print the acceptance summary from a JSON report
|
summarize print the acceptance summary from a JSON report
|
||||||
|
|
||||||
|
|
|
||||||
109
cmd/telesrv-update/main.go
Normal file
109
cmd/telesrv-update/main.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
runtimemetrics "runtime/metrics"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
@ -60,6 +61,7 @@ import (
|
||||||
"telesrv/internal/app/userprojection"
|
"telesrv/internal/app/userprojection"
|
||||||
"telesrv/internal/app/users"
|
"telesrv/internal/app/users"
|
||||||
verificationapp "telesrv/internal/app/verification"
|
verificationapp "telesrv/internal/app/verification"
|
||||||
|
welcomemessagesapp "telesrv/internal/app/welcomemessages"
|
||||||
"telesrv/internal/botapi"
|
"telesrv/internal/botapi"
|
||||||
"telesrv/internal/config"
|
"telesrv/internal/config"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
|
@ -79,6 +81,7 @@ import (
|
||||||
"telesrv/internal/store/redisstore"
|
"telesrv/internal/store/redisstore"
|
||||||
"telesrv/internal/telegramloginhttp"
|
"telesrv/internal/telegramloginhttp"
|
||||||
"telesrv/internal/turnsrv"
|
"telesrv/internal/turnsrv"
|
||||||
|
"telesrv/internal/updatecdn"
|
||||||
"telesrv/internal/web"
|
"telesrv/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -273,8 +276,9 @@ func startDebugServer(ctx context.Context, addr string, metricsHandler http.Hand
|
||||||
func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
|
func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
|
||||||
var mem runtime.MemStats
|
var mem runtime.MemStats
|
||||||
runtime.ReadMemStats(&mem)
|
runtime.ReadMemStats(&mem)
|
||||||
return []obsmetrics.GaugeSample{
|
samples := []obsmetrics.GaugeSample{
|
||||||
{Name: "telesrv_go_goroutines", Value: float64(runtime.NumGoroutine())},
|
{Name: "telesrv_go_goroutines", Value: float64(runtime.NumGoroutine())},
|
||||||
|
{Name: "telesrv_go_scheduler_busy_seconds", Value: goSchedulerBusySeconds()},
|
||||||
{Name: "telesrv_go_heap_alloc_bytes", Value: float64(mem.HeapAlloc)},
|
{Name: "telesrv_go_heap_alloc_bytes", Value: float64(mem.HeapAlloc)},
|
||||||
{Name: "telesrv_go_heap_inuse_bytes", Value: float64(mem.HeapInuse)},
|
{Name: "telesrv_go_heap_inuse_bytes", Value: float64(mem.HeapInuse)},
|
||||||
{Name: "telesrv_go_heap_objects", Value: float64(mem.HeapObjects)},
|
{Name: "telesrv_go_heap_objects", Value: float64(mem.HeapObjects)},
|
||||||
|
|
@ -283,6 +287,28 @@ func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
|
||||||
{Name: "telesrv_go_gc_cycles", Value: float64(mem.NumGC)},
|
{Name: "telesrv_go_gc_cycles", Value: float64(mem.NumGC)},
|
||||||
{Name: "telesrv_go_gc_pause_seconds", Value: time.Duration(mem.PauseTotalNs).Seconds()},
|
{Name: "telesrv_go_gc_pause_seconds", Value: time.Duration(mem.PauseTotalNs).Seconds()},
|
||||||
}
|
}
|
||||||
|
if value, ok := processCPUSeconds(); ok {
|
||||||
|
samples = append(samples, obsmetrics.GaugeSample{Name: "telesrv_process_cpu_seconds", Value: value})
|
||||||
|
}
|
||||||
|
return samples
|
||||||
|
}
|
||||||
|
|
||||||
|
// goSchedulerBusySeconds is a Go scheduler-class estimate. The runtime
|
||||||
|
// documentation explicitly warns that CPU-class values are overestimates and
|
||||||
|
// are not comparable to operating-system process CPU time, so capacity reports
|
||||||
|
// use telesrv_process_cpu_seconds instead.
|
||||||
|
func goSchedulerBusySeconds() float64 {
|
||||||
|
samples := []runtimemetrics.Sample{
|
||||||
|
{Name: "/cpu/classes/total:cpu-seconds"},
|
||||||
|
{Name: "/cpu/classes/idle:cpu-seconds"},
|
||||||
|
}
|
||||||
|
runtimemetrics.Read(samples)
|
||||||
|
total := samples[0].Value.Float64()
|
||||||
|
idle := samples[1].Value.Float64()
|
||||||
|
if total <= idle {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return total - idle
|
||||||
}
|
}
|
||||||
|
|
||||||
func mtprotoRuntimeGaugeSamples(snapshot mtprotoedge.RuntimeSnapshot) []obsmetrics.GaugeSample {
|
func mtprotoRuntimeGaugeSamples(snapshot mtprotoedge.RuntimeSnapshot) []obsmetrics.GaugeSample {
|
||||||
|
|
@ -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_ready_connections", Value: float64(snapshot.InboundRPCReadyConnections)},
|
||||||
{Name: "telesrv_mtproto_inbound_rpc_task_limit", Value: float64(snapshot.InboundRPCMaxTasks)},
|
{Name: "telesrv_mtproto_inbound_rpc_task_limit", Value: float64(snapshot.InboundRPCMaxTasks)},
|
||||||
{Name: "telesrv_mtproto_inbound_rpc_byte_limit", Value: float64(snapshot.InboundRPCMaxBytes)},
|
{Name: "telesrv_mtproto_inbound_rpc_byte_limit", Value: float64(snapshot.InboundRPCMaxBytes)},
|
||||||
|
{Name: "telesrv_mtproto_rpc_delivery_hook_workers", Value: float64(snapshot.RPCDeliveryHookWorkers)},
|
||||||
|
{Name: "telesrv_mtproto_rpc_delivery_hook_capacity", Value: float64(snapshot.RPCDeliveryHookCapacity)},
|
||||||
|
{Name: "telesrv_mtproto_rpc_delivery_hook_reserved", Value: float64(snapshot.RPCDeliveryHookReserved)},
|
||||||
|
{Name: "telesrv_mtproto_rpc_delivery_hook_queued", Value: float64(snapshot.RPCDeliveryHookQueued)},
|
||||||
|
{Name: "telesrv_mtproto_rpc_delivery_hook_running", Value: float64(snapshot.RPCDeliveryHookRunning)},
|
||||||
|
{Name: "telesrv_mtproto_rpc_delivery_hook_completed_total", Value: float64(snapshot.RPCDeliveryHookCompleted)},
|
||||||
|
{Name: "telesrv_mtproto_rpc_delivery_hook_rejected_total", Value: float64(snapshot.RPCDeliveryHookRejected)},
|
||||||
|
{Name: "telesrv_mtproto_rpc_delivery_hook_panics_total", Value: float64(snapshot.RPCDeliveryHookPanics)},
|
||||||
|
{Name: "telesrv_mtproto_rpc_delivery_hook_duration_seconds_total", Value: snapshot.RPCDeliveryHookDurationSeconds},
|
||||||
{Name: "telesrv_mtproto_inbound_frame_bytes", Value: float64(snapshot.InboundFrameBytes)},
|
{Name: "telesrv_mtproto_inbound_frame_bytes", Value: float64(snapshot.InboundFrameBytes)},
|
||||||
{Name: "telesrv_mtproto_inbound_frame_byte_limit", Value: float64(snapshot.InboundFrameMaxBytes)},
|
{Name: "telesrv_mtproto_inbound_frame_byte_limit", Value: float64(snapshot.InboundFrameMaxBytes)},
|
||||||
{Name: "telesrv_mtproto_outbound_tracked_bytes", Labels: []obsmetrics.Label{{Name: "kind", Value: "body"}}, Value: float64(snapshot.OutboundTrackedBytes)},
|
{Name: "telesrv_mtproto_outbound_tracked_bytes", Labels: []obsmetrics.Label{{Name: "kind", Value: "body"}}, Value: float64(snapshot.OutboundTrackedBytes)},
|
||||||
|
|
@ -445,15 +480,20 @@ type rpcProjectionVerificationNotifier struct {
|
||||||
invalidator interface {
|
invalidator interface {
|
||||||
InvalidateRPCProjectionReadModelForUser(userID int64)
|
InvalidateRPCProjectionReadModelForUser(userID int64)
|
||||||
InvalidateRPCProjectionReadModelForChannel(channelID int64)
|
InvalidateRPCProjectionReadModelForChannel(channelID int64)
|
||||||
|
InvalidatePeerIdentityReadModel(domain.Peer)
|
||||||
}
|
}
|
||||||
users storepkg.UserCache
|
users storepkg.UserCache
|
||||||
log *zap.Logger
|
peerIdentity bool
|
||||||
|
log *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n rpcProjectionVerificationNotifier) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
|
func (n rpcProjectionVerificationNotifier) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
|
||||||
if n.invalidator == nil {
|
if n.invalidator == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if n.peerIdentity {
|
||||||
|
n.invalidator.InvalidatePeerIdentityReadModel(peer)
|
||||||
|
}
|
||||||
switch peer.Type {
|
switch peer.Type {
|
||||||
case domain.PeerTypeUser:
|
case domain.PeerTypeUser:
|
||||||
n.invalidator.InvalidateRPCProjectionReadModelForUser(peer.ID)
|
n.invalidator.InvalidateRPCProjectionReadModelForUser(peer.ID)
|
||||||
|
|
@ -570,6 +610,15 @@ func run(logger *zap.Logger) error {
|
||||||
zap.Bool("schema_dirty", migrationStatus.Dirty),
|
zap.Bool("schema_dirty", migrationStatus.Dirty),
|
||||||
zap.Bool("schema_empty", migrationStatus.Empty),
|
zap.Bool("schema_empty", migrationStatus.Empty),
|
||||||
)
|
)
|
||||||
|
blobRuntimeLock, err := postgres.AcquireBlobRuntimeLock(ctx, cfg.PostgresDSN)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("acquire blob runtime lock: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := blobRuntimeLock.Close(); err != nil {
|
||||||
|
logger.Error("release blob runtime lock", zap.Error(err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
pool, err := postgres.Open(ctx, cfg.PostgresDSN,
|
pool, err := postgres.Open(ctx, cfg.PostgresDSN,
|
||||||
postgres.WithMaxConns(cfg.PostgresMaxConns),
|
postgres.WithMaxConns(cfg.PostgresMaxConns),
|
||||||
postgres.WithMinConns(cfg.PostgresMinConns),
|
postgres.WithMinConns(cfg.PostgresMinConns),
|
||||||
|
|
@ -649,7 +698,8 @@ func run(logger *zap.Logger) error {
|
||||||
if cfg.TelegramLoginEnabled {
|
if cfg.TelegramLoginEnabled {
|
||||||
telegramLoginHTTPHandler, err = telegramloginhttp.NewHandler(telegramloginhttp.Config{
|
telegramLoginHTTPHandler, err = telegramloginhttp.NewHandler(telegramloginhttp.Config{
|
||||||
Service: telegramLoginService, Tokens: telegramLoginIDTokens,
|
Service: telegramLoginService, Tokens: telegramLoginIDTokens,
|
||||||
Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName,
|
BotUsernames: postgres.NewUserStore(pool),
|
||||||
|
Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName,
|
||||||
Logger: logger.Named("telegram-login-http"), TrustedProxyCIDRs: cfg.TelegramLoginTrustedProxyCIDRs,
|
Logger: logger.Named("telegram-login-http"), TrustedProxyCIDRs: cfg.TelegramLoginTrustedProxyCIDRs,
|
||||||
AllowHTTP: cfg.TelegramLoginAllowHTTP,
|
AllowHTTP: cfg.TelegramLoginAllowHTTP,
|
||||||
})
|
})
|
||||||
|
|
@ -662,51 +712,157 @@ func run(logger *zap.Logger) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
authKeyStore := postgres.NewAuthKeyStore(pool)
|
authKeyStore := postgres.NewAuthKeyStore(pool)
|
||||||
|
authKeyGetBatchStore, err := postgres.NewBatchedAuthKeyStore(
|
||||||
|
authKeyStore,
|
||||||
|
postgres.AuthKeyGetBatchConfig{
|
||||||
|
MaxSize: cfg.AuthKeyGetBatchMax, MaxWait: cfg.AuthKeyGetBatchWait,
|
||||||
|
QueueSize: cfg.AuthKeyGetBatchQueue, QueryTimeout: cfg.AuthKeyGetBatchTimeout,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer authKeyGetBatchStore.Close()
|
||||||
|
authKeySessionLayerStore, err := postgres.NewBatchedAuthKeySessionLayerStore(
|
||||||
|
authKeyStore,
|
||||||
|
postgres.AuthKeySessionLayerBatchConfig{
|
||||||
|
MaxSize: cfg.LayerAdvanceBatchMax, MaxWait: cfg.LayerAdvanceBatchWait,
|
||||||
|
QueueSize: cfg.LayerAdvanceBatchQueue, QueryTimeout: cfg.LayerAdvanceBatchTimeout,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer authKeySessionLayerStore.Close()
|
||||||
userStore := postgres.NewUserStore(pool)
|
userStore := postgres.NewUserStore(pool)
|
||||||
authzStore := postgres.NewAuthorizationStore(pool)
|
authzStore := postgres.NewAuthorizationStore(pool)
|
||||||
adminStore := postgres.NewAdminStore(pool)
|
adminStore := postgres.NewAdminStore(pool)
|
||||||
updateStateStore := postgres.NewUpdateStateStore(pool)
|
updateStateStore := postgres.NewUpdateStateStore(pool)
|
||||||
updateEventStore := postgres.NewUpdateEventStore(pool, postgres.WithUpdateEventLogger(logger.Named("store").Named("updates")))
|
updateEventStore := postgres.NewUpdateEventStore(pool, postgres.WithUpdateEventLogger(logger.Named("store").Named("updates")))
|
||||||
phoneChangeStore := postgres.NewPhoneChangeStore(pool)
|
phoneChangeStore := postgres.NewPhoneChangeStore(pool)
|
||||||
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(postgres.NewReadModelVersionStore(pool), 0, 0)
|
readModelVersionBatchStore, err := storepkg.NewBatchedReadModelVersionStore(
|
||||||
|
postgres.NewReadModelVersionStore(pool),
|
||||||
|
storepkg.ReadModelVersionBatchConfig{
|
||||||
|
MaxKeys: cfg.ReadModelVersionBatchMaxKeys, MaxWait: cfg.ReadModelVersionBatchWait,
|
||||||
|
QueueSize: cfg.ReadModelVersionBatchQueue, QueryTimeout: cfg.ReadModelVersionBatchTimeout,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer readModelVersionBatchStore.Close()
|
||||||
|
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(
|
||||||
|
readModelVersionBatchStore,
|
||||||
|
0,
|
||||||
|
cfg.ReadModelVersionCacheMaxEntries,
|
||||||
|
)
|
||||||
|
dialogListSnapshotCache := redisstore.NewDialogListSnapshotCache(rdb, cfg.DialogListSnapshotRedisTTL)
|
||||||
|
activeChannelIDsPageCache := redisstore.NewActiveChannelIDsPageCache(rdb, cfg.ActiveChannelIDsRedisTTL)
|
||||||
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout))
|
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout))
|
||||||
bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool)
|
bootstrapUpdateStore, err := postgres.NewBatchedBootstrapUpdateJobStore(
|
||||||
|
postgres.NewBootstrapUpdateJobStore(pool),
|
||||||
|
postgres.BootstrapReadyBatchConfig{
|
||||||
|
MaxSize: cfg.BootstrapReadyBatchMax, MaxWait: cfg.BootstrapReadyBatchWait,
|
||||||
|
QueueSize: cfg.BootstrapReadyBatchQueue, QueryTimeout: cfg.BootstrapReadyBatchTimeout,
|
||||||
|
Metrics: metricRegistry,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer bootstrapUpdateStore.Close()
|
||||||
botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool)
|
botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool)
|
||||||
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
|
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
|
||||||
ephemeralStore := redisstore.NewEphemeralMessageStore(rdb)
|
ephemeralStore := redisstore.NewEphemeralMessageStore(rdb)
|
||||||
ephemeralReportStore := postgres.NewEphemeralReportStore(pool)
|
ephemeralReportStore := postgres.NewEphemeralReportStore(pool)
|
||||||
|
welcomeMessageStore := postgres.NewWelcomeMessageStore(pool)
|
||||||
moderationReportStore := postgres.NewModerationReportStore(pool)
|
moderationReportStore := postgres.NewModerationReportStore(pool)
|
||||||
authDeliveryReportStore := postgres.NewAuthDeliveryReportStore(pool)
|
authDeliveryReportStore := postgres.NewAuthDeliveryReportStore(pool)
|
||||||
clientTelemetryStore := postgres.NewClientTelemetryStore(pool)
|
clientTelemetryStore := postgres.NewClientTelemetryStore(pool)
|
||||||
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
|
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
|
||||||
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
|
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
|
||||||
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
|
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
|
||||||
secretChatIDAllocator := redisstore.NewSecretChatIDAllocator(rdb, postgres.NewSecretChatIDCounterSource(pool))
|
reverseContactStore, err := storepkg.NewBatchedReverseContactStore(
|
||||||
contactStore := userprojection.NewCachedContactStore(postgres.NewContactStore(pool), 0)
|
postgres.NewContactStore(pool),
|
||||||
|
storepkg.ReverseContactBatchConfig{
|
||||||
|
MaxPairs: cfg.ContactReverseBatchMaxPairs, MaxWait: cfg.ContactReverseBatchWait,
|
||||||
|
QueueSize: cfg.ContactReverseBatchQueue, QueryTimeout: cfg.ContactReverseBatchTimeout,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer reverseContactStore.Close()
|
||||||
|
contactStore := userprojection.NewCachedContactStoreWithMaxViewers(
|
||||||
|
reverseContactStore,
|
||||||
|
0,
|
||||||
|
cfg.ContactSnapshotCacheMaxViewers,
|
||||||
|
)
|
||||||
dialogStore := postgres.NewDialogStore(pool)
|
dialogStore := postgres.NewDialogStore(pool)
|
||||||
chatlistStore := postgres.NewChatlistStore(pool)
|
chatlistStore := postgres.NewChatlistStore(pool)
|
||||||
messageStore := postgres.NewMessageStore(pool,
|
messageStore := postgres.NewMessageStore(pool,
|
||||||
postgres.WithMessageAllocators(boxIDAllocator),
|
postgres.WithMessageAllocators(boxIDAllocator),
|
||||||
postgres.WithMessageLogger(logger.Named("store").Named("messages")))
|
postgres.WithMessageLogger(logger.Named("store").Named("messages")))
|
||||||
|
broadcastStore := postgres.NewBroadcastStore(pool)
|
||||||
|
broadcastService := broadcastapp.NewService(broadcastStore,
|
||||||
|
broadcastapp.WithMessageSender(messageStore),
|
||||||
|
broadcastapp.WithLogger(logger.Named("broadcast")))
|
||||||
// 共享频道行/成员缓存 + 统一 read-model LISTEN/NOTIFY 实时失效:消除高频「逐 RPC
|
// 共享频道行/成员缓存 + 统一 read-model LISTEN/NOTIFY 实时失效:消除高频「逐 RPC
|
||||||
// 解析频道/成员」在客户端重连同步突发里重复读同一行的放大。
|
// 解析频道/成员」在客户端重连同步突发里重复读同一行的放大。
|
||||||
channelRowCache := postgres.NewChannelRowCache(cfg.ChannelRowCacheMaxEntries)
|
channelRowCache := postgres.NewChannelRowCache(cfg.ChannelRowCacheMaxEntries)
|
||||||
|
channelTopMessageCache := postgres.NewChannelTopMessageCache(cfg.ChannelTopMessageCacheMaxEntries)
|
||||||
channelMemberCache := postgres.NewChannelMemberCache(cfg.ChannelMemberCacheMaxEntries)
|
channelMemberCache := postgres.NewChannelMemberCache(cfg.ChannelMemberCacheMaxEntries)
|
||||||
channelDialogCache := postgres.NewChannelDialogCache(cfg.ChannelDialogCacheMaxEntries)
|
channelDialogCache := postgres.NewChannelDialogCache(cfg.ChannelDialogCacheMaxEntries)
|
||||||
|
channelDifferenceCache := postgres.NewChannelDifferenceBaseCache(
|
||||||
|
cfg.ChannelDifferenceCacheMaxEntries,
|
||||||
|
cfg.ChannelDifferenceCacheMaxBytes,
|
||||||
|
cfg.ChannelDifferenceCacheTTL,
|
||||||
|
)
|
||||||
channelBoostCache := postgres.NewChannelBoostCache(cfg.ChannelBoostCacheMaxEntries, cfg.ChannelBoostCacheTTL)
|
channelBoostCache := postgres.NewChannelBoostCache(cfg.ChannelBoostCacheMaxEntries, cfg.ChannelBoostCacheTTL)
|
||||||
channelStore := postgres.NewChannelStore(pool,
|
channelStore := postgres.NewChannelStore(pool,
|
||||||
postgres.WithChannelAllocators(channelIDAllocator, channelMessageIDAllocator),
|
postgres.WithChannelAllocators(channelIDAllocator, channelMessageIDAllocator),
|
||||||
postgres.WithChannelLogger(logger.Named("store").Named("channels")),
|
postgres.WithChannelLogger(logger.Named("store").Named("channels")),
|
||||||
postgres.WithChannelRowCache(channelRowCache),
|
postgres.WithChannelRowCache(channelRowCache),
|
||||||
|
postgres.WithChannelTopMessageCache(channelTopMessageCache),
|
||||||
postgres.WithChannelMemberCache(channelMemberCache),
|
postgres.WithChannelMemberCache(channelMemberCache),
|
||||||
postgres.WithChannelDialogCache(channelDialogCache),
|
postgres.WithChannelDialogCache(channelDialogCache),
|
||||||
|
postgres.WithChannelDifferenceBaseCache(channelDifferenceCache),
|
||||||
postgres.WithChannelBoostCache(channelBoostCache))
|
postgres.WithChannelBoostCache(channelBoostCache))
|
||||||
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator)
|
activeChannelIDsPageBatcher, err := postgres.NewActiveChannelIDsPageBatcher(
|
||||||
|
channelStore,
|
||||||
|
postgres.ActiveChannelIDsBatchConfig{
|
||||||
|
MaxSize: cfg.ActiveChannelIDsBatchMax, MaxWait: cfg.ActiveChannelIDsBatchWait,
|
||||||
|
QueueSize: cfg.ActiveChannelIDsBatchQueue, QueryTimeout: cfg.ActiveChannelIDsBatchTimeout,
|
||||||
|
Metrics: metricRegistry,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer activeChannelIDsPageBatcher.Close()
|
||||||
|
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
|
||||||
|
snapshot := channelDifferenceCache.Snapshot()
|
||||||
|
return []obsmetrics.GaugeSample{
|
||||||
|
{Name: "telesrv_channel_difference_cache_entries", Value: float64(snapshot.Entries)},
|
||||||
|
{Name: "telesrv_channel_difference_cache_weight_bytes", Value: float64(snapshot.Weight)},
|
||||||
|
{Name: "telesrv_channel_difference_cache_hits", Value: float64(snapshot.Hits)},
|
||||||
|
{Name: "telesrv_channel_difference_cache_misses", Value: float64(snapshot.Misses)},
|
||||||
|
{Name: "telesrv_channel_difference_cache_loads", Value: float64(snapshot.Loads)},
|
||||||
|
{Name: "telesrv_channel_difference_cache_load_errors", Value: float64(snapshot.LoadErrors)},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
communityCatalogCache := postgres.NewCommunityCatalogCache()
|
||||||
|
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator,
|
||||||
|
postgres.WithCommunityCatalogCache(communityCatalogCache))
|
||||||
pollStore := postgres.NewPollStore(pool)
|
pollStore := postgres.NewPollStore(pool)
|
||||||
mediaStore := postgres.NewMediaStore(pool)
|
mediaStore := postgres.NewMediaStore(pool)
|
||||||
// 头像投影缓存:所有 projector 共用一层短 TTL owner→头像缓存,消除高频「返回用户」RPC
|
// 头像投影缓存:所有 projector 共用 owner→头像正/负 LRU。profile_photo NOTIFY
|
||||||
// 每次投影对每批 owner 固定 2 次的 CurrentProfilePhotosKind PG 查询。
|
// 精确失效负责正常新鲜度,长 TTL 只覆盖漏通知,避免登录 ramp 周期性重查稳定负值。
|
||||||
cachedPhotos := userprojection.NewCachedPhotoProvider(mediaStore, userprojection.DefaultPhotoCacheTTL)
|
cachedPhotos := userprojection.NewCachedPhotoProviderWithMaxEntries(
|
||||||
|
mediaStore,
|
||||||
|
cfg.ProfilePhotoCacheTTL,
|
||||||
|
cfg.ProfilePhotoCacheMaxEntries,
|
||||||
|
)
|
||||||
privacyStore := privacyapp.NewCachedPrivacyStore(postgres.NewPrivacyStore(pool), 0)
|
privacyStore := privacyapp.NewCachedPrivacyStore(postgres.NewPrivacyStore(pool), 0)
|
||||||
storyStore := postgres.NewStoryStore(pool)
|
storyStore := postgres.NewStoryStore(pool)
|
||||||
// Transient upload-part scratch storage always stays on local disk
|
// Transient upload-part scratch storage always stays on local disk
|
||||||
|
|
@ -902,6 +1058,11 @@ func run(logger *zap.Logger) error {
|
||||||
Commands: adminStore,
|
Commands: adminStore,
|
||||||
Restrictions: adminStore,
|
Restrictions: adminStore,
|
||||||
})
|
})
|
||||||
|
userProjectionFacts := userprojection.NewDurableUserProjectionFacts(
|
||||||
|
adminService,
|
||||||
|
readModelVersionStore,
|
||||||
|
cfg.UserProjectionFactCacheMaxEntries,
|
||||||
|
)
|
||||||
storageRetentionMaxAge := cfg.StorageRetentionMaxAge
|
storageRetentionMaxAge := cfg.StorageRetentionMaxAge
|
||||||
if !cfg.StorageRetentionEnable {
|
if !cfg.StorageRetentionEnable {
|
||||||
storageRetentionMaxAge = 0
|
storageRetentionMaxAge = 0
|
||||||
|
|
@ -932,7 +1093,7 @@ func run(logger *zap.Logger) error {
|
||||||
contactsService := contacts.NewService(contactStore, userStore).Configure(
|
contactsService := contacts.NewService(contactStore, userStore).Configure(
|
||||||
contacts.WithPhotoProvider(cachedPhotos),
|
contacts.WithPhotoProvider(cachedPhotos),
|
||||||
contacts.WithPrivacyEvaluator(privacyService),
|
contacts.WithPrivacyEvaluator(privacyService),
|
||||||
contacts.WithAccountFreezeProvider(adminService),
|
contacts.WithAccountFreezeProvider(userProjectionFacts),
|
||||||
contacts.WithReadModelVersions(readModelVersionStore),
|
contacts.WithReadModelVersions(readModelVersionStore),
|
||||||
contacts.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification),
|
contacts.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification),
|
||||||
)
|
)
|
||||||
|
|
@ -1036,6 +1197,33 @@ func run(logger *zap.Logger) error {
|
||||||
botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow),
|
botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow),
|
||||||
botsapp.WithPublicBaseURL(cfg.PublicBaseURL),
|
botsapp.WithPublicBaseURL(cfg.PublicBaseURL),
|
||||||
botsapp.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification))
|
botsapp.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification))
|
||||||
|
// The built-in ChatBot and StickersBot are seeded with the default product
|
||||||
|
// name in their bio (users.about) and description (bots.description). Align
|
||||||
|
// them with the active branding on startup so the seeded "telesrv" text is
|
||||||
|
// replaced. SetBotInfo writes both fields; the sync is a no-op when the text
|
||||||
|
// already matches.
|
||||||
|
for _, botID := range []int64{domain.ChatBotUserID, domain.StickersBotUserID} {
|
||||||
|
var wantAbout, wantDesc string
|
||||||
|
switch botID {
|
||||||
|
case domain.ChatBotUserID:
|
||||||
|
wantAbout = domain.ChatBotDescription()
|
||||||
|
wantDesc = wantAbout
|
||||||
|
case domain.StickersBotUserID:
|
||||||
|
wantAbout = domain.StickersBotDescription()
|
||||||
|
wantDesc = wantAbout
|
||||||
|
}
|
||||||
|
if _, curAbout, curDesc, err := botsService.GetBotInfo(ctx, botID); err == nil && curAbout == wantAbout && curDesc == wantDesc {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := botsService.SetBotInfo(ctx, botID, domain.BotInfoUpdate{
|
||||||
|
SetAbout: true,
|
||||||
|
About: wantAbout,
|
||||||
|
SetDescription: true,
|
||||||
|
Description: wantDesc,
|
||||||
|
}); err != nil {
|
||||||
|
logger.Warn("sync bot branding", zap.Int64("bot", botID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
groupCallStore := postgres.NewGroupCallStore(pool)
|
groupCallStore := postgres.NewGroupCallStore(pool)
|
||||||
groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL))
|
groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL))
|
||||||
// 群通话媒体面:内嵌 pion SFU(M1+)。SFU 的 liveness reporter 把媒体面存活
|
// 群通话媒体面:内嵌 pion SFU(M1+)。SFU 的 liveness reporter 把媒体面存活
|
||||||
|
|
@ -1120,7 +1308,7 @@ func run(logger *zap.Logger) error {
|
||||||
// 私聊端对端加密(Secret Chat)握手状态机 + qts 投递队列(盲中继)。
|
// 私聊端对端加密(Secret Chat)握手状态机 + qts 投递队列(盲中继)。
|
||||||
secretChatStore := postgres.NewSecretChatStore(pool)
|
secretChatStore := postgres.NewSecretChatStore(pool)
|
||||||
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
|
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
|
||||||
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator)
|
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore)
|
||||||
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
|
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
|
||||||
// 同属进程内一次性凭据,不跨实例)。
|
// 同属进程内一次性凭据,不跨实例)。
|
||||||
passkeyStore := postgres.NewPasskeyStore(pool)
|
passkeyStore := postgres.NewPasskeyStore(pool)
|
||||||
|
|
@ -1129,7 +1317,7 @@ func run(logger *zap.Logger) error {
|
||||||
passkeyapp.WithAllowedOrigins(cfg.PasskeyAllowedOrigins))
|
passkeyapp.WithAllowedOrigins(cfg.PasskeyAllowedOrigins))
|
||||||
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
|
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
|
||||||
themeService := themesapp.NewService(postgres.NewThemeStore(pool))
|
themeService := themesapp.NewService(postgres.NewThemeStore(pool))
|
||||||
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(adminService), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), 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)
|
privacyService.ConfigureReadModels(usersService, channelStore)
|
||||||
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
|
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
|
||||||
botsService.SetAIChatGenerator(aiComposeService)
|
botsService.SetAIChatGenerator(aiComposeService)
|
||||||
|
|
@ -1137,9 +1325,21 @@ func run(logger *zap.Logger) error {
|
||||||
dialogs.WithContactStore(contactStore),
|
dialogs.WithContactStore(contactStore),
|
||||||
dialogs.WithPhotoProvider(cachedPhotos),
|
dialogs.WithPhotoProvider(cachedPhotos),
|
||||||
dialogs.WithPrivacyEvaluator(privacyService),
|
dialogs.WithPrivacyEvaluator(privacyService),
|
||||||
dialogs.WithAccountFreezeProvider(adminService),
|
dialogs.WithAccountFreezeProvider(userProjectionFacts),
|
||||||
dialogs.WithPremiumChecker(usersService.PremiumActive),
|
dialogs.WithPremiumChecker(usersService.PremiumActive),
|
||||||
dialogs.WithReadModelVersions(readModelVersionStore),
|
dialogs.WithReadModelVersions(readModelVersionStore),
|
||||||
|
dialogs.WithDialogHydrationCaches(
|
||||||
|
cfg.DialogPrivatePeerCacheMaxEntries,
|
||||||
|
cfg.DialogPrivatePeerCacheMaxBytes,
|
||||||
|
cfg.DialogDraftCacheMaxEntries,
|
||||||
|
cfg.DialogDraftCacheMaxBytes,
|
||||||
|
),
|
||||||
|
dialogs.WithDialogListSnapshotCache(
|
||||||
|
cfg.DialogListSnapshotCacheMaxEntries,
|
||||||
|
cfg.DialogListSnapshotCacheMaxHeaders,
|
||||||
|
cfg.DialogListSnapshotCacheTTL,
|
||||||
|
),
|
||||||
|
dialogs.WithSharedDialogListSnapshotCache(dialogListSnapshotCache),
|
||||||
)
|
)
|
||||||
// 编译期保证 *users.Service 满足 channel fan-out 跨 viewer 投影预热的可选能力;签名漂移会在
|
// 编译期保证 *users.Service 满足 channel fan-out 跨 viewer 投影预热的可选能力;签名漂移会在
|
||||||
// 这里立刻断编译,而非在运行时静默退化回 O(viewer) 逐 viewer 投影。
|
// 这里立刻断编译,而非在运行时静默退化回 O(viewer) 逐 viewer 投影。
|
||||||
|
|
@ -1147,11 +1347,19 @@ func run(logger *zap.Logger) error {
|
||||||
channelsService := channelapp.NewService(channelStore,
|
channelsService := channelapp.NewService(channelStore,
|
||||||
channelapp.WithBotProfileResolver(botsService),
|
channelapp.WithBotProfileResolver(botsService),
|
||||||
channelapp.WithReadModelVersions(readModelVersionStore),
|
channelapp.WithReadModelVersions(readModelVersionStore),
|
||||||
|
channelapp.WithActiveChannelIDsReadModel(
|
||||||
|
activeChannelIDsPageCache,
|
||||||
|
activeChannelIDsPageBatcher,
|
||||||
|
cfg.ActiveChannelIDsCacheMaxEntries,
|
||||||
|
cfg.ActiveChannelIDsCacheTTL,
|
||||||
|
metricRegistry,
|
||||||
|
),
|
||||||
channelapp.WithSendPermissionChecker(adminService),
|
channelapp.WithSendPermissionChecker(adminService),
|
||||||
channelapp.WithReservedUsernames(cfg.ReservedUsernames),
|
channelapp.WithReservedUsernames(cfg.ReservedUsernames),
|
||||||
)
|
)
|
||||||
communitiesService := communitiesapp.NewService(communityStore)
|
communitiesService := communitiesapp.NewService(communityStore)
|
||||||
ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService)
|
ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService)
|
||||||
|
welcomeMessageService := welcomemessagesapp.NewService(welcomeMessageStore, channelsService)
|
||||||
storiesService := storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService))
|
storiesService := storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService))
|
||||||
chatlistsService := chatlistsapp.NewService(
|
chatlistsService := chatlistsapp.NewService(
|
||||||
chatlistStore,
|
chatlistStore,
|
||||||
|
|
@ -1164,7 +1372,7 @@ func run(logger *zap.Logger) error {
|
||||||
messageapp.WithContactStore(contactStore),
|
messageapp.WithContactStore(contactStore),
|
||||||
messageapp.WithPhotoProvider(cachedPhotos),
|
messageapp.WithPhotoProvider(cachedPhotos),
|
||||||
messageapp.WithPrivacyEvaluator(privacyService),
|
messageapp.WithPrivacyEvaluator(privacyService),
|
||||||
messageapp.WithAccountFreezeProvider(adminService),
|
messageapp.WithAccountFreezeProvider(userProjectionFacts),
|
||||||
messageapp.WithReadModelVersions(readModelVersionStore),
|
messageapp.WithReadModelVersions(readModelVersionStore),
|
||||||
messageapp.WithBotResponder(botsService),
|
messageapp.WithBotResponder(botsService),
|
||||||
messageapp.WithSendPermissionChecker(adminService),
|
messageapp.WithSendPermissionChecker(adminService),
|
||||||
|
|
@ -1191,7 +1399,7 @@ func run(logger *zap.Logger) error {
|
||||||
dialogStore,
|
dialogStore,
|
||||||
newTranslationOptions(cfg, rateLimiter, logger)...,
|
newTranslationOptions(cfg, rateLimiter, logger)...,
|
||||||
)
|
)
|
||||||
authService := auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode,
|
authService := auth.NewService(userStore, authzStore, codeStore, authKeyGetBatchStore, tempAuthKeyStore, cfg.DevAuthCode,
|
||||||
auth.WithLoginMessages(messageStore, dialogStore),
|
auth.WithLoginMessages(messageStore, dialogStore),
|
||||||
auth.WithLoginCodeDelivery(messageStore),
|
auth.WithLoginCodeDelivery(messageStore),
|
||||||
auth.WithPasswords(passwordStore),
|
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))
|
logger.Info("default verifier seed complete", zap.Int64("bot_id", domain.VerifierBotUserID))
|
||||||
}
|
}
|
||||||
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
|
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
|
||||||
|
var appUpdateResolver updatecdn.Resolver
|
||||||
|
if cfg.UpdateServiceURL != "" {
|
||||||
|
client, err := updatecdn.NewClient(cfg.UpdateServiceURL, cfg.UpdateRequestTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("initialize update service client: %w", err)
|
||||||
|
}
|
||||||
|
appUpdateResolver = client
|
||||||
|
}
|
||||||
router := rpc.New(rpc.Config{
|
router := rpc.New(rpc.Config{
|
||||||
DC: cfg.DC,
|
DC: cfg.DC,
|
||||||
DefaultCountryCode: cfg.DefaultCountryCode,
|
DefaultCountryCode: cfg.DefaultCountryCode,
|
||||||
|
|
@ -1304,17 +1520,29 @@ func run(logger *zap.Logger) error {
|
||||||
GroupCallMaxParticipants: cfg.GroupCallMaxParticipants,
|
GroupCallMaxParticipants: cfg.GroupCallMaxParticipants,
|
||||||
RtmpIngestURL: cfg.LiveStreamRtmpURL,
|
RtmpIngestURL: cfg.LiveStreamRtmpURL,
|
||||||
PublicBaseURL: cfg.PublicBaseURL,
|
PublicBaseURL: cfg.PublicBaseURL,
|
||||||
|
UpdatePublicURL: cfg.UpdatePublicURL,
|
||||||
PublicAppScheme: cfg.PublicAppScheme,
|
PublicAppScheme: cfg.PublicAppScheme,
|
||||||
PublicAppLinkBase: cfg.PublicAppLinkBase,
|
PublicAppLinkBase: cfg.PublicAppLinkBase,
|
||||||
// PFS temp→perm 解析缓存:显式撤销会清缓存并断开连接,re-bind 即时失效;
|
// PFS temp→perm 解析缓存:显式撤销会清缓存并断开连接,re-bind 即时失效;
|
||||||
// 配置 TTL 只承担跨进程/异常失效兜底,避免大连接数周期性打满 PG。
|
// 配置 TTL 只承担跨进程/异常失效兜底,避免大连接数周期性打满 PG。
|
||||||
TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL,
|
TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL,
|
||||||
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
|
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
|
||||||
|
PeerIdentityCacheMaxEntries: cfg.PeerIdentityCacheMaxEntries,
|
||||||
|
StoryActivePeerCacheMaxEntries: cfg.StoryActivePeerCacheMaxEntries,
|
||||||
|
StoryHiddenListCacheMaxEntries: cfg.StoryHiddenListCacheMaxEntries,
|
||||||
|
StoryHiddenListCacheMaxBytes: cfg.StoryHiddenListCacheMaxBytes,
|
||||||
|
PresenceLastSeenBatchMax: cfg.PresenceLastSeenBatchMax,
|
||||||
|
PresenceLastSeenBatchWait: cfg.PresenceLastSeenBatchWait,
|
||||||
|
PresenceLastSeenBatchQueue: cfg.PresenceLastSeenBatchQueue,
|
||||||
|
PresenceLastSeenBatchTimeout: cfg.PresenceLastSeenBatchTimeout,
|
||||||
|
PresenceLastSeenDrainTimeout: cfg.PresenceLastSeenDrainTimeout,
|
||||||
}, rpc.Deps{
|
}, rpc.Deps{
|
||||||
Auth: authService,
|
Auth: authService,
|
||||||
AuthDeliveryReports: authDeliveryReportService,
|
AuthDeliveryReports: authDeliveryReportService,
|
||||||
ClientTelemetry: clientTelemetryService,
|
ClientTelemetry: clientTelemetryService,
|
||||||
AuthKeySessionLayers: authKeyStore,
|
AuthKeySessionLayers: authKeySessionLayerStore,
|
||||||
|
ReadModelVersions: readModelVersionStore,
|
||||||
|
UserProjectionFacts: userProjectionFacts,
|
||||||
Account: accountService,
|
Account: accountService,
|
||||||
Privacy: privacyService,
|
Privacy: privacyService,
|
||||||
Help: help.NewService(helpStore, helpStore,
|
Help: help.NewService(helpStore, helpStore,
|
||||||
|
|
@ -1323,73 +1551,77 @@ func run(logger *zap.Logger) error {
|
||||||
help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
|
help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
|
||||||
help.WithAccountFreezeProvider(adminService),
|
help.WithAccountFreezeProvider(adminService),
|
||||||
),
|
),
|
||||||
AccountFreeze: adminService,
|
AppUpdates: appUpdateResolver,
|
||||||
AICompose: aiComposeService,
|
AccountFreeze: userProjectionFacts,
|
||||||
Ephemeral: ephemeralService,
|
AccountFreezeNotifications: adminService,
|
||||||
EphemeralPush: ephemeralStore,
|
AICompose: aiComposeService,
|
||||||
Moderation: moderationService,
|
Ephemeral: ephemeralService,
|
||||||
Users: usersService,
|
EphemeralPush: ephemeralStore,
|
||||||
Usernames: usernamesService,
|
WelcomeMessages: welcomeMessageService,
|
||||||
BotVerifications: botVerificationService,
|
Moderation: moderationService,
|
||||||
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
|
Users: usersService,
|
||||||
Updates: updatesService,
|
Usernames: usernamesService,
|
||||||
BootstrapUpdates: bootstrapUpdateStore,
|
BotVerifications: botVerificationService,
|
||||||
BotAPIUpdates: botAPIUpdateStore,
|
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
|
||||||
BotCallbacks: botCallbackStore,
|
Updates: updatesService,
|
||||||
Contacts: contactsService,
|
BootstrapUpdates: bootstrapUpdateStore,
|
||||||
Dialogs: dialogsService,
|
BotAPIUpdates: botAPIUpdateStore,
|
||||||
Chatlists: chatlistsService,
|
BotCallbacks: botCallbackStore,
|
||||||
Messages: messagesService,
|
Contacts: contactsService,
|
||||||
Translation: translationService,
|
Dialogs: dialogsService,
|
||||||
Channels: channelsService,
|
Chatlists: chatlistsService,
|
||||||
Communities: communitiesService,
|
Messages: messagesService,
|
||||||
Files: filesService,
|
Translation: translationService,
|
||||||
PremiumPromo: filesService,
|
Channels: channelsService,
|
||||||
Bots: botsService,
|
Communities: communitiesService,
|
||||||
ServiceBotCallbacks: botsService,
|
Files: filesService,
|
||||||
ServiceBotInlineResults: botsService,
|
PremiumPromo: filesService,
|
||||||
Polls: pollsapp.NewService(pollStore),
|
Bots: botsService,
|
||||||
Stories: storiesService,
|
ServiceBotCallbacks: botsService,
|
||||||
Phone: phoneService,
|
ServiceBotInlineResults: botsService,
|
||||||
SecretChats: secretChatService,
|
Polls: pollsapp.NewService(pollStore),
|
||||||
Passkey: passkeyService,
|
Stories: storiesService,
|
||||||
Themes: themeService,
|
Phone: phoneService,
|
||||||
GroupCalls: groupCallsService,
|
SecretChats: secretChatService,
|
||||||
LiveStreams: liveStreamDep(liveStreamService),
|
Passkey: passkeyService,
|
||||||
SFU: sfuService,
|
Themes: themeService,
|
||||||
TURN: turnService,
|
GroupCalls: groupCallsService,
|
||||||
LangPack: langPackService,
|
LiveStreams: liveStreamDep(liveStreamService),
|
||||||
Sessions: activeSessions,
|
SFU: sfuService,
|
||||||
Metrics: metricRegistry,
|
TURN: turnService,
|
||||||
Inline: inlineRegistryStore,
|
LangPack: langPackService,
|
||||||
Limiter: rateLimiter,
|
Sessions: activeSessions,
|
||||||
|
Metrics: metricRegistry,
|
||||||
|
Inline: inlineRegistryStore,
|
||||||
|
Limiter: rateLimiter,
|
||||||
}, logger.Named("rpc"), clock.System)
|
}, logger.Named("rpc"), clock.System)
|
||||||
readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{
|
readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{
|
||||||
ReadModelVersions: readModelVersionStore,
|
ReadModelVersions: readModelVersionStore,
|
||||||
ChannelRows: channelRowCache,
|
ChannelRows: channelRowCache,
|
||||||
ChannelMembers: channelMemberCache,
|
ChannelTopMessages: channelTopMessageCache,
|
||||||
ChannelDialogs: channelDialogCache,
|
CommunityCatalog: communityCatalogCache,
|
||||||
ChannelBoosts: channelBoostCache,
|
ChannelMembers: channelMemberCache,
|
||||||
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
|
ChannelDialogs: channelDialogCache,
|
||||||
Dialogs: dialogsService,
|
ChannelDifferences: channelDifferenceCache,
|
||||||
Privacy: privacyService,
|
ChannelBoosts: channelBoostCache,
|
||||||
ProfilePhotos: cachedPhotos,
|
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
|
||||||
Stories: router,
|
Dialogs: dialogsService,
|
||||||
ChannelFullBots: router,
|
Privacy: privacyService,
|
||||||
ChannelBotMembers: channelsService,
|
ProfilePhotos: cachedPhotos,
|
||||||
ChannelMediaCounts: channelsService,
|
Stories: router,
|
||||||
PrivateMediaCounts: messagesService,
|
ChannelFullBots: router,
|
||||||
RPCProjections: router,
|
ChannelBotMembers: channelsService,
|
||||||
BaseUsers: userCache,
|
ChannelMediaCounts: channelsService,
|
||||||
BotProfiles: botsService,
|
PrivateMediaCounts: messagesService,
|
||||||
AccountSettings: router,
|
RPCProjections: router,
|
||||||
|
PeerIdentities: router,
|
||||||
|
BaseUsers: userCache,
|
||||||
|
BotProfiles: botsService,
|
||||||
|
AccountSettings: router,
|
||||||
|
UserProjectionFacts: userProjectionFacts,
|
||||||
}, logger.Named("store").Named("read-model-listener"))
|
}, logger.Named("store").Named("read-model-listener"))
|
||||||
go readModelListener.Run(ctx)
|
go readModelListener.Run(ctx)
|
||||||
activeSessions.SetLifecycleObserver(router)
|
activeSessions.SetLifecycleObserver(router)
|
||||||
broadcastStore := postgres.NewBroadcastStore(pool)
|
|
||||||
broadcastService := broadcastapp.NewService(broadcastStore,
|
|
||||||
broadcastapp.WithMessageSender(messageStore),
|
|
||||||
broadcastapp.WithLogger(logger.Named("broadcast")))
|
|
||||||
adminService.Configure(adminapp.Dependencies{
|
adminService.Configure(adminapp.Dependencies{
|
||||||
Auth: authService,
|
Auth: authService,
|
||||||
Revoker: router,
|
Revoker: router,
|
||||||
|
|
@ -1453,9 +1685,10 @@ func run(logger *zap.Logger) error {
|
||||||
if notifier, ok := any(router).(botverificationapp.PeerNotifier); ok {
|
if notifier, ok := any(router).(botverificationapp.PeerNotifier); ok {
|
||||||
botVerificationService.SetPeerNotifier(compositeBotVerificationNotifier{
|
botVerificationService.SetPeerNotifier(compositeBotVerificationNotifier{
|
||||||
cache: rpcProjectionVerificationNotifier{
|
cache: rpcProjectionVerificationNotifier{
|
||||||
invalidator: router,
|
invalidator: router,
|
||||||
users: userCache,
|
users: userCache,
|
||||||
log: verificationLogger,
|
peerIdentity: true,
|
||||||
|
log: verificationLogger,
|
||||||
},
|
},
|
||||||
edge: notifier,
|
edge: notifier,
|
||||||
})
|
})
|
||||||
|
|
@ -1474,7 +1707,9 @@ func run(logger *zap.Logger) error {
|
||||||
// not wait on however long sending to all of them takes.
|
// not wait on however long sending to all of them takes.
|
||||||
go broadcastapp.NewWorker(broadcastService, logger.Named("broadcast").Named("delivery"),
|
go broadcastapp.NewWorker(broadcastService, logger.Named("broadcast").Named("delivery"),
|
||||||
cfg.BroadcastWorkerInterval, cfg.BroadcastWorkerBatch).Run(ctx)
|
cfg.BroadcastWorkerInterval, cfg.BroadcastWorkerBatch).Run(ctx)
|
||||||
moderationActionOptions := []moderationapp.ActionExecutorOption{}
|
moderationActionOptions := []moderationapp.ActionExecutorOption{
|
||||||
|
moderationapp.WithAccountDeletionNotifier(router),
|
||||||
|
}
|
||||||
if cfg.PublicLinkWebAddr != "" {
|
if cfg.PublicLinkWebAddr != "" {
|
||||||
moderationActionOptions = append(
|
moderationActionOptions = append(
|
||||||
moderationActionOptions,
|
moderationActionOptions,
|
||||||
|
|
@ -1503,6 +1738,7 @@ func run(logger *zap.Logger) error {
|
||||||
rpc.WithOutboxUpdateBuilder(router.BuildOutboxUpdates),
|
rpc.WithOutboxUpdateBuilder(router.BuildOutboxUpdates),
|
||||||
).Run(ctx)
|
).Run(ctx)
|
||||||
go rpc.NewBootstrapUpdateDispatcher(router, logger.Named("rpc").Named("bootstrap")).Run(ctx)
|
go rpc.NewBootstrapUpdateDispatcher(router, logger.Named("rpc").Named("bootstrap")).Run(ctx)
|
||||||
|
go rpc.NewWelcomeDeliveryDispatcher(router, welcomeMessageStore, logger.Named("rpc").Named("welcome-delivery")).Run(ctx)
|
||||||
go rpc.NewScheduledDispatcher(router, logger.Named("rpc").Named("scheduled")).Run(ctx)
|
go rpc.NewScheduledDispatcher(router, logger.Named("rpc").Named("scheduled")).Run(ctx)
|
||||||
go rpc.NewSuggestedPostDispatcher(router, logger.Named("rpc").Named("suggested-post")).Run(ctx)
|
go rpc.NewSuggestedPostDispatcher(router, logger.Named("rpc").Named("suggested-post")).Run(ctx)
|
||||||
go rpc.NewExpiryDispatcher(router, logger.Named("rpc").Named("expiry")).Run(ctx)
|
go rpc.NewExpiryDispatcher(router, logger.Named("rpc").Named("expiry")).Run(ctx)
|
||||||
|
|
@ -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 rpc.NewGroupCallSweepDispatcher(router, logger.Named("rpc").Named("groupcall-sweep"), cfg.GroupCallSweepInterval, cfg.GroupCallCheckTTL).Run(ctx)
|
||||||
go router.RunChannelFanout(ctx)
|
go router.RunChannelFanout(ctx)
|
||||||
go router.RunBotAPIEnqueue(ctx)
|
go router.RunBotAPIEnqueue(ctx)
|
||||||
|
go router.RunPresenceLastSeenBatch(ctx)
|
||||||
go router.RunPresenceSweeper(ctx, time.Minute)
|
go router.RunPresenceSweeper(ctx, time.Minute)
|
||||||
go activeSessions.RunPendingSweeper(ctx, time.Minute)
|
go activeSessions.RunPendingSweeper(ctx, time.Minute)
|
||||||
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
|
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
|
||||||
|
|
@ -1567,7 +1804,7 @@ func run(logger *zap.Logger) error {
|
||||||
RSAKey: rsaKey,
|
RSAKey: rsaKey,
|
||||||
IdentityDir: cfg.IdentityDir,
|
IdentityDir: cfg.IdentityDir,
|
||||||
LayerRPC: router,
|
LayerRPC: router,
|
||||||
AuthKeys: authKeyStore,
|
AuthKeys: authKeyGetBatchStore,
|
||||||
ActiveSessions: activeSessions,
|
ActiveSessions: activeSessions,
|
||||||
Metrics: metricRegistry,
|
Metrics: metricRegistry,
|
||||||
ObfuscatedTCP: true,
|
ObfuscatedTCP: true,
|
||||||
|
|
@ -1582,6 +1819,8 @@ func run(logger *zap.Logger) error {
|
||||||
RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers,
|
RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers,
|
||||||
RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks,
|
RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks,
|
||||||
RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes,
|
RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes,
|
||||||
|
RPCDeliveryHookWorkers: cfg.MTProtoRPCDeliveryHookWorkers,
|
||||||
|
RPCDeliveryHookMaxPending: cfg.MTProtoRPCDeliveryHookMaxPending,
|
||||||
RPCExecutionMaxEntries: cfg.MTProtoRPCExecutionMaxEntries,
|
RPCExecutionMaxEntries: cfg.MTProtoRPCExecutionMaxEntries,
|
||||||
RPCExecutionAuthMaxEntries: cfg.MTProtoRPCExecutionAuthMaxEntries,
|
RPCExecutionAuthMaxEntries: cfg.MTProtoRPCExecutionAuthMaxEntries,
|
||||||
RPCExecutionSessionMaxEntries: cfg.MTProtoRPCExecutionSessionMaxEntries,
|
RPCExecutionSessionMaxEntries: cfg.MTProtoRPCExecutionSessionMaxEntries,
|
||||||
|
|
|
||||||
7
cmd/telesrv/process_cpu_fallback.go
Normal file
7
cmd/telesrv/process_cpu_fallback.go
Normal 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
|
||||||
|
}
|
||||||
12
cmd/telesrv/process_cpu_test.go
Normal file
12
cmd/telesrv/process_cpu_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
cmd/telesrv/process_cpu_unix.go
Normal file
17
cmd/telesrv/process_cpu_unix.go
Normal 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
|
||||||
|
}
|
||||||
21
cmd/telesrv/process_cpu_windows.go
Normal file
21
cmd/telesrv/process_cpu_windows.go
Normal 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
100
deploy/docker/.env.example
Normal 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
|
||||||
1
deploy/docker/assets/test-server-rsa.pem.b64
Normal file
1
deploy/docker/assets/test-server-rsa.pem.b64
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBeEYvLzBNMCsvNVB6Z2ROYWdUWCtKK2RKZ3I3NVpDVHVpRzhpNHg3WXdtSkYramlPCkdDam03WDdCTENhTWMxK2hPWllETDMrR3ZsZS9BS3lrVzFxb3VhQ0pNVngvSCsybDhMRlhMZWxaMlBMYXdUYjgKQTdCbFRxV3pMM2RiNUJ1Z01OV3ppTDlUdWhSOEluMWJ3S1kwN1FWcFI5aW41empBc0FHTEJrK21HdDBEblZ5TQpmMVhvcDJsTENGTm1tMEY0eWtjQWVhTENDSVBiR1dkZGxpTFk4eEVFaEk0R08ybDFVM2taTXdJT2RPbkFHSkZ0CmdVQW9UZStGSFI2RjFzOWFkQ1ZaQjF0ZUwvaGY5UitXbWVrSnd5Z1Z6ME1ZRUg3eTZVNDlUNDUrL1c3T0Y2WDYKZzBXMGoxdVNTcnNZNHFON3R3eGJUYWQ5emRHWjd5cys5ditQdVFJREFRQUJBb0lCQUFqdXFPOHhkczBmU0xNKwpEdDdUdXVUTHcyODhDcElBa0EwS3FSYVZuNXh2NWVqMHk1blR1blZSRDY1WGJvb003b04xREY0THVmQk1nM2FmClk3WjRFRGFwVTdRNEZkdzQ3aFJkcks1ODc4WkxmYUhPUTNaVGZyZ3VGMUZ3WjNDZnhSQ1RsOS8vZStwNTVnK1gKamlYY0tZb2lkZUI3dlY5cUdIR3BFRTdRTHFrSUVpbk1FV05hQjh1dGN0SDdUWGRXYTRweWZJR2lQckhNTjJ6SApoRjQwSWI3bkpBNmtodHpzTkNEU0Q5WG5ibEVORW9kMUU1Z1JzalE5ZkdzaGRCdHBEc1hyTEJGTDdLTUREb1FtCmN6cnQvS3hsWk1wYnRPZno2dWE1ZUtFQkJUdE51dG1WY3AxcTl5K2NRcFBaem5ZaVJUTCtPSnpWZ2Z1SE9PLzAKZWEyai93RUNnWUVBNkk2ZitFTG84QVA3UTRXMHFzc3VYS3ZIRUYvUkVFZngyTHVlaDBsclVoVlIzSWdqYWtGYwpiVFJsTEVRSzhRTmJEQW1OTXVIckVabmptR3U3bnkzek45NHNsQy95Y0k1cGQ5aFFHVndPMjJlcjlvMitNOXcyCkpwQjRRZjhjOFNLL3BIUU45K2pPdEJ4VjJkcmJmZTBvVW9LRXRZeUQ1Y2J0akZUM3lmZUEyQ0VDZ1lFQTJDdW8KUzg0MWtWcHB4MUt3cEw0aTFmZ0dRUHlrYUtyRDRvR1pyWWd2MkZ4VzU1aS9xNUZjWUw2ZkUzN0phTTFwby9SQgp0bkhnMzVOYW5nL3l1Wlh6NkViNEswQ3lMKzhMdWhDSWI2UHhEdnF5Q1hia1hUd0hTVlJBbkdFTVNOM3phLzdGCkZDSkYxQUJXZzhqbEtSZEEvbFJ3cW84UDhaZ0JlQk1xcEtaaDVKa0NnWUJoQ0ltazI3NDN6MkY2dGdKQk5VL2QKNk9yQllVbHBJcXU5ZytOTWpZelREZ1EvSVNxdHZpSGppdllmOXpBZGlnbm1SdUg4ZGhsUUdjYkdKVVYrMEh4bwpOaktoampQNVZPS2ExODNzRnVZNEU5VERwamJUaXJHcGU2UkIzVUZsTjl1QXNjL1dQZlJwWUYxTjdpeWhLV0FtCnRVRE1RNW9ST09TTEpqVFJ0NHl5SVFLQmdRQ1RmeTVsRXYyd0FQMzk5K2o1YjVhN1luRjU5Q2lHRmtaMERiUDcKR05wMGlZVHVuMlhndmQxSFVhbWZGcnA4blBRQTM4L2FtZGN6RmdzVm9KSWdtVFdFZnJBa2F3OXA3M1NUNzJYNApydWJ6THBFK0xmWmh1MnpKVndpQzZ5RURzeFc5MFdkTmRwa29yMVpZczBIUmlNRmJCK2ljSitOY0dEaWdZb3VOCkxzM0t1UUtCZ1FDVEF4Vk03ek5QZ3NmZnVkWlA3Rk1ueTIwTE1SNitGUW83eFJ6NUVsWWFDVmdYTTFOSDRLL3IKeUZrT0NHaE9ONkkvTVVOQlB4V0xFdVpCUmZ5cmx5M2JwM2o3UjYvaExYNGZOeXc5QjNHUXVWeUNIRDJENHRvZAo2SjVWby9Fc2VxcGVlS2E2Q3YvWTJIVkIwa2ZHdzFWQ0MvZ00wMUw4dWVkM2hzcjJMRDJrQ3c9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=
|
||||||
8
deploy/docker/assets/test-server-rsa.pub
Normal file
8
deploy/docker/assets/test-server-rsa.pub
Normal 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-----
|
||||||
63
deploy/docker/compose.bridge-network.yaml
Normal file
63
deploy/docker/compose.bridge-network.yaml
Normal 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
228
deploy/docker/compose.yaml
Normal 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:
|
||||||
99
deploy/docker/docker-entrypoint.sh
Executable file
99
deploy/docker/docker-entrypoint.sh
Executable 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 "$@"
|
||||||
|
|
@ -2648,7 +2648,9 @@ CREATE TABLE public.secret_chats (
|
||||||
history_deleted boolean DEFAULT false NOT NULL,
|
history_deleted boolean DEFAULT false NOT NULL,
|
||||||
random_id integer NOT NULL,
|
random_id integer NOT NULL,
|
||||||
date integer NOT NULL,
|
date integer NOT NULL,
|
||||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT secret_chats_nonzero_id CHECK ((chat_id <> 0)),
|
||||||
|
CONSTRAINT secret_chats_random_id_is_chat_id CHECK ((chat_id = random_id))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -4524,13 +4526,6 @@ CREATE INDEX upload_parts_object_key_idx ON public.upload_parts USING btree (obj
|
||||||
CREATE UNIQUE INDEX uq_emq_dedup ON public.encrypted_message_queue USING btree (receiver_auth_key_id, chat_id, random_id);
|
CREATE UNIQUE INDEX uq_emq_dedup ON public.encrypted_message_queue USING btree (receiver_auth_key_id, chat_id, random_id);
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: uq_secret_chats_admin_random; Type: INDEX; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX uq_secret_chats_admin_random ON public.secret_chats USING btree (admin_auth_key_id, random_id) WHERE (state <> 'discarded'::text);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: user_channel_member_index_admined_public_idx; Type: INDEX; Schema: public; Owner: -
|
-- Name: user_channel_member_index_admined_public_idx; Type: INDEX; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP TABLE IF EXISTS broadcast_recipients;
|
||||||
|
DROP TABLE IF EXISTS broadcasts;
|
||||||
54
deploy/migrations/20260901000001_system_broadcasts.up.sql
Normal file
54
deploy/migrations/20260901000001_system_broadcasts.up.sql
Normal 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);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE broadcasts
|
||||||
|
DROP CONSTRAINT IF EXISTS broadcasts_entities_array_check,
|
||||||
|
DROP COLUMN IF EXISTS entities;
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -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();
|
||||||
129
deploy/migrations/20260901000004_logical_account_deletion.up.sql
Normal file
129
deploy/migrations/20260901000004_logical_account_deletion.up.sql
Normal 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;
|
||||||
|
$$;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP TABLE IF EXISTS welcome_messages;
|
||||||
|
DROP TABLE IF EXISTS welcome_message_peers;
|
||||||
31
deploy/migrations/20260901000007_welcome_messages.up.sql
Normal file
31
deploy/migrations/20260901000007_welcome_messages.up.sql
Normal 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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP TABLE IF EXISTS welcome_message_deliveries;
|
||||||
|
DROP SEQUENCE IF EXISTS welcome_message_join_event_id_seq;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
DROP INDEX IF EXISTS welcome_message_deliveries_target_idx;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
CREATE INDEX welcome_message_deliveries_target_idx
|
||||||
|
ON welcome_message_deliveries (channel_id, target_user_id, id);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
DROP FUNCTION IF EXISTS public.telesrv_advance_auth_session_layer(
|
||||||
|
bigint,
|
||||||
|
bigint,
|
||||||
|
integer,
|
||||||
|
bigint,
|
||||||
|
timestamptz
|
||||||
|
);
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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;
|
||||||
|
$$;
|
||||||
|
|
@ -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;
|
||||||
|
$$;
|
||||||
|
|
@ -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;
|
||||||
|
$$;
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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;
|
||||||
|
$$;
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
DROP FUNCTION IF EXISTS public.telesrv_bind_temp_auth_key(
|
||||||
|
bigint,
|
||||||
|
bigint,
|
||||||
|
bigint,
|
||||||
|
bigint,
|
||||||
|
integer,
|
||||||
|
bytea
|
||||||
|
);
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
DROP INDEX IF EXISTS public.bootstrap_update_jobs_pending_auth_idx;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
$$;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
11
deploy/postgres-init/010_branch_databases.sql
Normal file
11
deploy/postgres-init/010_branch_databases.sql
Normal 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
|
||||||
46
deploy/update/manifest.example.json
Normal file
46
deploy/update/manifest.example.json
Normal 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
403
docs/admin-panel-api.en.md
Normal 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
416
docs/admin-panel-api.ru.md
Normal 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`).
|
||||||
BIN
docs/assets/gramsrv-android.png
Normal file
BIN
docs/assets/gramsrv-android.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
BIN
docs/assets/gramsrv-telegram-desktop.png
Normal file
BIN
docs/assets/gramsrv-telegram-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 488 KiB |
83
docs/local-setup.md
Normal file
83
docs/local-setup.md
Normal 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
175
docs/update-service.md
Normal 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
3
go.mod
|
|
@ -9,9 +9,10 @@ require (
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
github.com/gotd/ige v0.3.0
|
github.com/gotd/ige v0.3.0
|
||||||
github.com/gotd/log/logzap v0.1.1
|
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/pgerrcode v0.0.0-20220416144525-469b46aa5efa
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
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/lestrrat-go/jwx/v3 v3.1.1
|
||||||
github.com/minio/minio-go/v7 v7.2.1
|
github.com/minio/minio-go/v7 v7.2.1
|
||||||
github.com/pion/datachannel v1.6.2
|
github.com/pion/datachannel v1.6.2
|
||||||
|
|
|
||||||
8
go.sum
8
go.sum
|
|
@ -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/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 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI=
|
||||||
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
|
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.3.2 h1:/EwvDU0oiArAof16WDDGfUXg/w02m2MqzDskCnXakZA=
|
||||||
github.com/iamxvbaba/td v1.2.1/go.mod h1:INkZJi18XbXtVOrldDnPtmQCJvIXhgDZdbo9MV/NY7M=
|
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 h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
|
||||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
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/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 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
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 h1:QaWeKm2KZ2zy7NkqqO1Vdl5idNqlG+svxdgwVAX+zbo=
|
||||||
github.com/ogen-go/ogen v1.23.0/go.mod h1:bwwvC3AmCV+LrL5lazyQwwof90402mdcSyI0FOzzpfM=
|
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=
|
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/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 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|
|
||||||
95
internal/admin/gif_catalog_test.go
Normal file
95
internal/admin/gif_catalog_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -207,10 +209,15 @@ type UsersService interface {
|
||||||
// AccountService carries the login-email factor (account_passwords table),
|
// AccountService carries the login-email factor (account_passwords table),
|
||||||
// a separate concern from UsersService's users-table fields.
|
// a separate concern from UsersService's users-table fields.
|
||||||
type AccountService interface {
|
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 force-sets a user's login/signup email, no OTP required.
|
||||||
SetLoginEmail(ctx context.Context, userID int64, email string) error
|
SetLoginEmail(ctx context.Context, userID int64, email string) error
|
||||||
// ClearLoginEmail removes the login email factor entirely.
|
// ClearLoginEmail removes the login email factor entirely.
|
||||||
ClearLoginEmail(ctx context.Context, userID int64) error
|
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
|
// 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
|
// ValidateStickerMaterialUpload is a pure check (no store writes) so a dry-run
|
||||||
// preview can validate an uploaded file's shape without materializing it.
|
// preview can validate an uploaded file's shape without materializing it.
|
||||||
ValidateStickerMaterialUpload(fileName string, data []byte) (mimeType string, ok bool)
|
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)
|
AdminUploadStickerMaterial(ctx context.Context, fileName string, data []byte) (domain.Document, error)
|
||||||
AdminCreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []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)
|
AdminAddStickerToSet(ctx context.Context, setID int64, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error)
|
||||||
|
|
@ -695,9 +707,10 @@ type RemoveStickerFromSetRequest struct {
|
||||||
|
|
||||||
type CreateGifCatalogEntryRequest struct {
|
type CreateGifCatalogEntryRequest struct {
|
||||||
CommandMeta
|
CommandMeta
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
FileName string `json:"file_name"`
|
FileName string `json:"file_name"`
|
||||||
Data []byte `json:"-"`
|
Data []byte `json:"-"`
|
||||||
|
ContentSHA256 string `json:"content_sha256,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetGifCatalogEnabledRequest struct {
|
type SetGifCatalogEnabledRequest struct {
|
||||||
|
|
@ -937,8 +950,9 @@ type TransferCollectibleUsernameRequest struct {
|
||||||
// permanently when Burn is set.
|
// permanently when Burn is set.
|
||||||
type RevokeCollectibleUsernameRequest struct {
|
type RevokeCollectibleUsernameRequest struct {
|
||||||
CommandMeta
|
CommandMeta
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Burn bool `json:"burn"`
|
ExpectedOwnerUserID int64 `json:"expected_owner_user_id,string,omitempty"`
|
||||||
|
Burn bool `json:"burn"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteCollectibleUsernameRequest erases a collectible asset entirely. Unlike a
|
// 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 {
|
if req.UserID <= 0 {
|
||||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
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 {
|
if s == nil || s.users == nil {
|
||||||
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
|
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
|
||||||
}
|
}
|
||||||
firstName := strings.TrimSpace(req.FirstName)
|
req.FirstName = strings.TrimSpace(req.FirstName)
|
||||||
lastName := strings.TrimSpace(req.LastName)
|
req.LastName = strings.TrimSpace(req.LastName)
|
||||||
return s.runCommand(ctx, req.CommandMeta, ActionSetProfile, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
return s.runCommand(ctx, req.CommandMeta, ActionSetProfile, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||||
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1379,18 +1396,21 @@ func (s *Service) SetProfile(ctx context.Context, req SetProfileRequest) (Comman
|
||||||
return CommandResult{}, domain.ErrUserNotFound
|
return CommandResult{}, domain.ErrUserNotFound
|
||||||
}
|
}
|
||||||
details := map[string]any{
|
details := map[string]any{
|
||||||
"previous_first_name": u.FirstName, "previous_last_name": u.LastName,
|
"previous_first_name": u.FirstName,
|
||||||
"new_first_name": firstName, "new_last_name": lastName,
|
"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 {
|
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{
|
updated, err := s.users.UpdateProfile(ctx, req.UserID, domain.UserProfileUpdate{
|
||||||
FirstName: firstName, HasFirstName: true,
|
FirstName: req.FirstName, HasFirstName: true,
|
||||||
LastName: lastName, HasLastName: true,
|
LastName: req.LastName, HasLastName: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CommandResult{}, err
|
return CommandResult{Details: details}, err
|
||||||
}
|
}
|
||||||
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
||||||
details["notify_error"] = err.Error()
|
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 {
|
if s == nil || s.users == nil {
|
||||||
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
|
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) {
|
return s.runCommand(ctx, req.CommandMeta, ActionSetPhone, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||||
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1418,15 +1441,22 @@ func (s *Service) SetPhone(ctx context.Context, req SetPhoneRequest) (CommandRes
|
||||||
if !found {
|
if !found {
|
||||||
return CommandResult{}, domain.ErrUserNotFound
|
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 {
|
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 {
|
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 {
|
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
||||||
details["notify_error"] = err.Error()
|
details["notify_error"] = err.Error()
|
||||||
}
|
}
|
||||||
|
|
@ -1442,26 +1472,46 @@ func (s *Service) SetLoginEmail(ctx context.Context, req SetLoginEmailRequest) (
|
||||||
if req.UserID <= 0 {
|
if req.UserID <= 0 {
|
||||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||||
}
|
}
|
||||||
if s == nil || s.account == nil {
|
if s == nil || s.users == nil || s.account == nil {
|
||||||
return CommandResult{}, fmt.Errorf("admin account dependency is not configured")
|
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) {
|
return s.runCommand(ctx, req.CommandMeta, ActionSetLoginEmail, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||||
details := map[string]any{"new_login_email": email}
|
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||||
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)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CommandResult{}, err
|
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"
|
message := "login email updated"
|
||||||
if email == "" {
|
if req.Email == "" {
|
||||||
message = "login email cleared"
|
message = "login email cleared"
|
||||||
}
|
}
|
||||||
return CommandResult{Message: message, Details: details}, nil
|
return CommandResult{Message: message, Details: details}, nil
|
||||||
|
|
@ -1475,31 +1525,37 @@ func (s *Service) SetAccountAvatar(ctx context.Context, req SetAccountAvatarRequ
|
||||||
if req.UserID <= 0 {
|
if req.UserID <= 0 {
|
||||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||||
}
|
}
|
||||||
if s == nil || s.photos == nil {
|
if domain.IsSystemUserID(req.UserID) {
|
||||||
return CommandResult{}, fmt.Errorf("admin photos dependency is not configured")
|
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) {
|
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
|
||||||
return CommandResult{}, domain.ErrPhotoInvalid
|
return CommandResult{}, domain.ErrPhotoInvalid
|
||||||
}
|
}
|
||||||
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountAvatar, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
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 {
|
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)
|
photo, err := s.photos.CreateAvatarFromBytes(ctx, req.Data, req.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CommandResult{Details: details}, err
|
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
|
return CommandResult{Details: details}, err
|
||||||
}
|
}
|
||||||
details["photo_id"] = photo.ID
|
details["photo_id"] = strconv.FormatInt(photo.ID, 10)
|
||||||
if s.users != nil {
|
if err := s.notifyUserChanged(ctx, u); err != nil {
|
||||||
if u, found, uerr := s.users.AdminUser(ctx, req.UserID); uerr == nil && found {
|
details["notify_error"] = err.Error()
|
||||||
if nerr := s.notifyUserChanged(ctx, u); nerr != nil {
|
|
||||||
details["notify_error"] = nerr.Error()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return CommandResult{Message: "avatar updated", Details: details}, nil
|
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 {
|
if req.ChannelID <= 0 {
|
||||||
return CommandResult{}, fmt.Errorf("channel_id is required")
|
return CommandResult{}, fmt.Errorf("channel_id is required")
|
||||||
}
|
}
|
||||||
if s == nil || s.photos == nil {
|
if s == nil || s.channels == nil || s.photos == nil {
|
||||||
return CommandResult{}, fmt.Errorf("admin photos dependency is not configured")
|
return CommandResult{}, fmt.Errorf("admin channel avatar dependencies are not configured")
|
||||||
}
|
|
||||||
if s.channels == nil {
|
|
||||||
return CommandResult{}, fmt.Errorf("admin channel dependency is not configured")
|
|
||||||
}
|
}
|
||||||
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
|
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
|
||||||
return CommandResult{}, domain.ErrPhotoInvalid
|
return CommandResult{}, domain.ErrPhotoInvalid
|
||||||
}
|
}
|
||||||
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
|
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
|
||||||
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelAvatar, 0, target, req, func() (CommandResult, error) {
|
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 {
|
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)
|
photo, err := s.photos.CreateAvatarFromBytes(ctx, req.Data, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1537,7 +1601,7 @@ func (s *Service) SetChannelAvatar(ctx context.Context, req SetChannelAvatarRequ
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CommandResult{Details: details}, err
|
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 {
|
if err := s.notifyChannelChanged(ctx, updated); err != nil {
|
||||||
details["notify_error"] = err.Error()
|
details["notify_error"] = err.Error()
|
||||||
}
|
}
|
||||||
|
|
@ -1562,38 +1626,10 @@ func (s *Service) ChannelAvatar(ctx context.Context, channelID int64) ([]byte, s
|
||||||
return nil, "", false, nil
|
return nil, "", false, nil
|
||||||
}
|
}
|
||||||
photo, found, err := s.photos.GetPhoto(ctx, channel.PhotoID)
|
photo, found, err := s.photos.GetPhoto(ctx, channel.PhotoID)
|
||||||
if err != nil {
|
if err != nil || !found {
|
||||||
return nil, "", false, err
|
return nil, "", found, err
|
||||||
}
|
}
|
||||||
if !found {
|
return s.avatarBytes(ctx, photo)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetUserColor force-sets or clears a user's name/profile color.
|
// 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) {
|
if !domain.ValidCollectibleUsername(req.Username) {
|
||||||
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
|
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) {
|
return s.runCommand(ctx, req.CommandMeta, ActionRevokeCollectibleUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||||
details := map[string]any{"username": req.Username, "burn": req.Burn}
|
details := map[string]any{"username": req.Username, "burn": req.Burn}
|
||||||
asset, err := s.usernames.Collectible(ctx, req.Username)
|
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() {
|
if !req.Burn && !asset.Owned() {
|
||||||
return CommandResult{Details: details}, codedError(CodeCollectibleNotOwned, domain.ErrCollectibleUsernameNotOwned)
|
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 {
|
if req.DryRun {
|
||||||
return CommandResult{Message: "collectible username revoke validated", Details: details}, nil
|
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 {
|
if s == nil || s.auth == nil || s.revoker == nil {
|
||||||
return CommandResult{}, fmt.Errorf("admin auth dependencies are not configured")
|
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 CommandResult{}, fmt.Errorf("choose one revoke mode")
|
||||||
}
|
}
|
||||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeSessions, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
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{
|
details := map[string]any{
|
||||||
"target_hashes": authorizationHashes(targets),
|
"target_hashes": authorizationHashes(targets),
|
||||||
"target_count": len(targets),
|
"target_count": len(targets),
|
||||||
"keep_hash": keep.Hash,
|
"keep_hash": authorizationHashString(keep.Hash),
|
||||||
}
|
}
|
||||||
if req.DryRun {
|
if req.DryRun {
|
||||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
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 {
|
if err != nil {
|
||||||
return CommandResult{}, err
|
return CommandResult{}, err
|
||||||
}
|
}
|
||||||
if found {
|
if !found {
|
||||||
revoked = append(revoked, deleted)
|
return CommandResult{}, fmt.Errorf("authorization hash not found")
|
||||||
}
|
}
|
||||||
|
revoked = append(revoked, deleted)
|
||||||
} else {
|
} else {
|
||||||
deleted, err := s.auth.ResetAuthorizations(ctx, req.UserID, keep.AuthKeyID)
|
deleted, err := s.auth.ResetAuthorizations(ctx, req.UserID, keep.AuthKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -2544,12 +2597,13 @@ func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, stri
|
||||||
return nil, "", false, nil
|
return nil, "", false, nil
|
||||||
}
|
}
|
||||||
photo, found, err := s.photos.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, userID, domain.ProfilePhotoKindProfile)
|
photo, found, err := s.photos.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, userID, domain.ProfilePhotoKindProfile)
|
||||||
if err != nil {
|
if err != nil || !found {
|
||||||
return nil, "", false, err
|
return nil, "", found, err
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return nil, "", false, nil
|
|
||||||
}
|
}
|
||||||
|
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)
|
size, inline, ok := bestAccountPhotoSize(photo.Sizes)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, "", false, nil
|
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),
|
LocationKey: fmt.Sprintf("photo:%d:%s", photo.ID, size.Type),
|
||||||
Limit: MaxAccountAvatarBytes + 1,
|
Limit: MaxAccountAvatarBytes + 1,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil || !found {
|
||||||
return nil, "", false, err
|
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
|
return nil, "", false, nil
|
||||||
}
|
}
|
||||||
data = chunk.Bytes
|
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 {
|
if len(data) == 0 || len(data) > MaxAccountAvatarBytes {
|
||||||
return nil, "", false, nil
|
return nil, "", false, nil
|
||||||
}
|
}
|
||||||
detected := http.DetectContentType(data)
|
mimeType := http.DetectContentType(data)
|
||||||
if !safeAccountImageType(detected) {
|
if !safeAccountImageType(mimeType) {
|
||||||
return nil, "", false, nil
|
return nil, "", false, nil
|
||||||
}
|
}
|
||||||
return data, detected, true, nil
|
return data, mimeType, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func bestAccountPhotoSize(sizes []domain.PhotoSize) (domain.PhotoSize, []byte, bool) {
|
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) {
|
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 {
|
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) {
|
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}
|
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) {
|
func (s *Service) RenameStickerSet(ctx context.Context, req RenameStickerSetRequest) (CommandResult, error) {
|
||||||
if s == nil || s.stickerSets == nil || req.SetID <= 0 || strings.TrimSpace(req.Title) == "" {
|
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) {
|
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}
|
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) {
|
func (s *Service) DeleteStickerSet(ctx context.Context, req DeleteStickerSetRequest) (CommandResult, error) {
|
||||||
if s == nil || s.stickerSets == nil || req.SetID <= 0 {
|
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) {
|
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)}
|
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) {
|
func (s *Service) CreateStickerSet(ctx context.Context, req CreateStickerSetRequest) (CommandResult, error) {
|
||||||
if s == nil || s.stickerSets == nil {
|
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) == "" {
|
if strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.ShortName) == "" || strings.TrimSpace(req.Emoji) == "" {
|
||||||
return CommandResult{}, domain.ErrStickerSetFileInvalid
|
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),
|
"title": req.Title, "short_name": req.ShortName, "kind": string(kind),
|
||||||
"file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
|
"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 {
|
if req.DryRun {
|
||||||
return CommandResult{Message: "sticker pack validated", Details: details}, nil
|
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) {
|
func (s *Service) AddStickerToSet(ctx context.Context, req AddStickerToSetRequest) (CommandResult, error) {
|
||||||
if s == nil || s.stickerSets == nil || req.SetID <= 0 {
|
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) == "" {
|
if strings.TrimSpace(req.Emoji) == "" {
|
||||||
return CommandResult{}, domain.ErrStickerSetEmojiInvalid
|
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,
|
"set_id": strconv.FormatInt(req.SetID, 10), "emoji": req.Emoji,
|
||||||
"file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
|
"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 {
|
if req.DryRun {
|
||||||
return CommandResult{Message: "sticker upload validated", Details: details}, nil
|
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) {
|
func (s *Service) RemoveStickerFromSet(ctx context.Context, req RemoveStickerFromSetRequest) (CommandResult, error) {
|
||||||
if s == nil || s.stickerSets == nil || req.SetID <= 0 || req.DocumentID <= 0 {
|
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) {
|
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)}
|
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) {
|
func (s *Service) CreateGifCatalogEntry(ctx context.Context, req CreateGifCatalogEntryRequest) (CommandResult, error) {
|
||||||
if s == nil || s.gifCatalog == nil {
|
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) == "" {
|
if strings.TrimSpace(req.Title) == "" {
|
||||||
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
|
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
|
||||||
|
|
@ -2822,10 +2894,19 @@ func (s *Service) CreateGifCatalogEntry(ctx context.Context, req CreateGifCatalo
|
||||||
if !ok {
|
if !ok {
|
||||||
return CommandResult{}, domain.ErrGifCatalogFileInvalid
|
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) {
|
return s.runCommand(ctx, req.CommandMeta, ActionCreateGifCatalogEntry, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||||
details := map[string]any{
|
details := map[string]any{
|
||||||
"title": req.Title, "file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
|
"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 {
|
if req.DryRun {
|
||||||
return CommandResult{Message: "gif catalog entry validated", Details: details}, nil
|
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) {
|
func (s *Service) SetGifCatalogEnabled(ctx context.Context, req SetGifCatalogEnabledRequest) (CommandResult, error) {
|
||||||
if s == nil || s.gifCatalog == nil || req.ID <= 0 {
|
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) {
|
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}
|
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) {
|
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 {
|
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) {
|
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}
|
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) {
|
func (s *Service) DeleteGifCatalogEntry(ctx context.Context, req DeleteGifCatalogEntryRequest) (CommandResult, error) {
|
||||||
if s == nil || s.gifCatalog == nil || req.ID <= 0 {
|
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) {
|
return s.runCommand(ctx, req.CommandMeta, ActionDeleteGifCatalogEntry, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||||
details := map[string]any{"id": strconv.FormatInt(req.ID, 10)}
|
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 []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
|
var keep domain.Authorization
|
||||||
if req.KeepHash != 0 {
|
if req.KeepHash != 0 {
|
||||||
|
|
@ -3227,15 +3308,26 @@ func revokeTargets(items []domain.Authorization, req RevokeSessionsRequest) ([]d
|
||||||
return targets, keep, nil
|
return targets, keep, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func authorizationHashes(items []domain.Authorization) []int64 {
|
func authorizationHashes(items []domain.Authorization) []string {
|
||||||
out := make([]int64, 0, len(items))
|
hashes := make([]int64, 0, len(items))
|
||||||
for _, a := range 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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authorizationHashString(hash int64) string {
|
||||||
|
if hash == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(hash, 10)
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeIDs(ids []int) ([]int, error) {
|
func normalizeIDs(ids []int) ([]int, error) {
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return nil, domain.ErrMessageIDInvalid
|
return nil, domain.ErrMessageIDInvalid
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,12 @@ const (
|
||||||
// decide how much a third-party mark is worth. Handing out the queue is
|
// decide how much a third-party mark is worth. Handing out the queue is
|
||||||
// routine; handing out the ability to appoint verifiers is not.
|
// routine; handing out the ability to appoint verifiers is not.
|
||||||
PermissionBotVerificationManage = "botverification.manage"
|
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
|
// CodeForbidden is the stable code for a permission failure, so the panel can
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ func (s *Server) routes() http.Handler {
|
||||||
mux.HandleFunc("POST /v1/bots/create", s.authenticated(s.handleCreateBot))
|
mux.HandleFunc("POST /v1/bots/create", s.authenticated(s.handleCreateBot))
|
||||||
mux.HandleFunc("POST /v1/broadcasts/create", s.authenticated(s.handleCreateBroadcast))
|
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/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", s.authenticated(s.handleDeleteMessages))
|
||||||
mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory))
|
mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory))
|
||||||
mux.HandleFunc("POST /v1/stickers/set-archived", s.authenticated(s.handleSetStickerSetArchived))
|
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) {
|
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
|
var req admin.SetAccountAvatarRequest
|
||||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
if !s.decodeAvatarUpload(w, r, &req.FileName, &req.Data) {
|
||||||
dec.DisallowUnknownFields()
|
|
||||||
if err := dec.Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
file, header, err := r.FormFile("file")
|
if !decodeMultipartMetadata(w, r, &req) {
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "avatar file is required")
|
|
||||||
return
|
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)
|
result, err := s.svc.SetAccountAvatar(r.Context(), req)
|
||||||
writeCommandResult(w, result, err)
|
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) {
|
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))
|
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20))
|
||||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if r.MultipartForm != nil {
|
if r.MultipartForm != nil {
|
||||||
defer r.MultipartForm.RemoveAll()
|
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")
|
file, header, err := r.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "avatar file is required")
|
writeError(w, http.StatusBadRequest, "avatar file is required")
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
|
raw, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
|
||||||
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarBytes {
|
if err != nil || len(raw) == 0 || int64(len(raw)) > admin.MaxAccountAvatarBytes {
|
||||||
writeError(w, http.StatusBadRequest, "avatar file is empty or too large")
|
writeError(w, http.StatusBadRequest, "avatar file is empty or too large")
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
req.FileName = header.Filename
|
*fileName = header.Filename
|
||||||
req.Data = data
|
*data = raw
|
||||||
result, err := s.svc.SetChannelAvatar(r.Context(), req)
|
return true
|
||||||
writeCommandResult(w, result, err)
|
}
|
||||||
|
|
||||||
|
// 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) {
|
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) {
|
func (s *Server) handleCreateStickerSet(w http.ResponseWriter, r *http.Request) {
|
||||||
defer r.Body.Close()
|
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 {
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||||
return
|
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) {
|
func (s *Server) handleAddStickerToSet(w http.ResponseWriter, r *http.Request) {
|
||||||
defer r.Body.Close()
|
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 {
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,22 @@ import (
|
||||||
|
|
||||||
const accountDeletionDelay = 7 * 24 * time.Hour
|
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
|
// DeleteAccount implements the official 2FA deletion decision. A supplied and
|
||||||
// valid SRP proof always deletes immediately. Without a proof, an account whose
|
// 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
|
// 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
|
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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -141,9 +141,3 @@ func (f *fakeAccountLifecycleStore) CancelAccountDeletion(_ context.Context, use
|
||||||
func (*fakeAccountLifecycleStore) DueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionCandidate, error) {
|
func (*fakeAccountLifecycleStore) DueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionCandidate, error) {
|
||||||
return nil, nil
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -15,18 +15,6 @@ import (
|
||||||
"telesrv/internal/store"
|
"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 消费的改号验证码。
|
// SendChangePhoneCode 创建只允许当前 user + perm auth_key 消费的改号验证码。
|
||||||
// CodeStore 会按 purpose+user+auth_key+phone 原子轮换:同一作用域的新请求
|
// CodeStore 会按 purpose+user+auth_key+phone 原子轮换:同一作用域的新请求
|
||||||
// 立即使旧 hash 失效,避免 Android 返回重进页面时留下并行有效验证码。
|
// 立即使旧 hash 失效,避免 Android 返回重进页面时留下并行有效验证码。
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ func TestPhoneChangeScopesCodeAndPersistsDurableEvent(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("change phone after session reconnect: %v", err)
|
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)
|
t.Fatalf("change result = %+v", result)
|
||||||
}
|
}
|
||||||
if got := f.changes.lastRequest().ExcludeAuthKeyID; got != rawAuthKeyID {
|
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)
|
t.Fatalf("new phone resolves to %+v found=%v", got, found)
|
||||||
}
|
}
|
||||||
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
|
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)
|
t.Fatalf("durable events = %+v err=%v", events, err)
|
||||||
}
|
}
|
||||||
if _, found, _ := f.codes.Get(f.ctx, hash); found {
|
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) {
|
func TestPhoneChangeWrongCodeExhaustsAttempts(t *testing.T) {
|
||||||
f := newPhoneChangeFixture(t)
|
f := newPhoneChangeFixture(t)
|
||||||
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012005")
|
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)
|
t.Fatalf("new hash change: %v", err)
|
||||||
}
|
}
|
||||||
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
|
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)
|
t.Fatalf("events = %+v err=%v", events, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPhoneChangeConcurrentReplayAppendsOneEvent(t *testing.T) {
|
func TestPhoneChangeConcurrentReplayChangesOnceWithoutPTSEvent(t *testing.T) {
|
||||||
f := newPhoneChangeFixture(t)
|
f := newPhoneChangeFixture(t)
|
||||||
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012007")
|
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012007")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -273,7 +293,7 @@ func TestPhoneChangeConcurrentReplayAppendsOneEvent(t *testing.T) {
|
||||||
t.Fatalf("successes=%d expired=%d", successes, expired)
|
t.Fatalf("successes=%d expired=%d", successes, expired)
|
||||||
}
|
}
|
||||||
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
|
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)
|
t.Fatalf("events = %+v err=%v", events, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1053,6 +1053,12 @@ func (s *Service) SetLoginEmail(ctx context.Context, userID int64, email string)
|
||||||
return s.passwords.Save(ctx, userID, settings)
|
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)。
|
// LoginEmail 返回已登录用户的登录邮箱原始地址(用于 verifyEmail 回显 emailVerified.email)。
|
||||||
func (s *Service) LoginEmail(ctx context.Context, userID int64) (string, bool, error) {
|
func (s *Service) LoginEmail(ctx context.Context, userID int64) (string, bool, error) {
|
||||||
if s == nil || s.passwords == nil || userID == 0 {
|
if s == nil || s.passwords == nil || userID == 0 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestWebhookExistingAccountRejectionKeepsDurableAppCode(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
users := memory.NewUserStore()
|
users := memory.NewUserStore()
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,10 @@ func validPhone(phone string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func systemUserLoginForbidden(u domain.User) 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 {
|
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 绑定。
|
// 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 {
|
if s.authKeys != nil {
|
||||||
inner, protocolExpiresAt, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
|
inner, protocolExpiresAt, result, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return domain.TempAuthKeyBindingResult{}, err
|
||||||
}
|
}
|
||||||
|
validated = result
|
||||||
binding.TempSessionID = inner.TempSessionID
|
binding.TempSessionID = inner.TempSessionID
|
||||||
// The bind request's expires_at is a signed client assertion. TDesktop
|
// The bind request's expires_at is a signed client assertion. TDesktop
|
||||||
// intentionally adds a small grace interval, while Android derives its
|
// 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
|
// 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
|
// absolute boundary and the encrypted proof may cross it. This is a temp-key
|
||||||
// rotation condition, never a destructive permanent-key proof failure.
|
// rotation condition, never a destructive permanent-key proof failure.
|
||||||
return ErrTempAuthKeyEmpty
|
return domain.TempAuthKeyBindingResult{}, ErrTempAuthKeyEmpty
|
||||||
}
|
}
|
||||||
if s.tempKeys == nil {
|
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) {
|
if errors.Is(err, store.ErrTempAuthKeyAlreadyBound) {
|
||||||
return ErrTempAuthKeyAlreadyBound
|
return domain.TempAuthKeyBindingResult{}, ErrTempAuthKeyAlreadyBound
|
||||||
}
|
}
|
||||||
if errors.Is(err, store.ErrAuthKeyBindingInvalid) {
|
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。
|
// 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。
|
// UserID 返回 auth_key 当前绑定的用户。未登录、或两步验证未完成时 found=false。
|
||||||
func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) {
|
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
|
return 0, false, nil
|
||||||
}
|
}
|
||||||
a, found, err := s.auths.ByAuthKey(ctx, authKeyID)
|
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 继续。
|
// 两步验证未完成:业务鉴权视为未登录,仅允许 auth.checkPassword 继续。
|
||||||
return 0, false, nil
|
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)
|
_ = s.auths.Delete(ctx, authKeyID)
|
||||||
return 0, false, nil
|
return 0, false, nil
|
||||||
}
|
}
|
||||||
|
|
@ -344,14 +356,18 @@ func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, e
|
||||||
// PendingPasswordUserID 返回处于"待两步验证"状态的 auth_key 对应的用户。
|
// PendingPasswordUserID 返回处于"待两步验证"状态的 auth_key 对应的用户。
|
||||||
// UserID 对 password_pending 的 auth_key 返回未登录,auth.checkPassword 借此仍能定位待验证用户。
|
// UserID 对 password_pending 的 auth_key 返回未登录,auth.checkPassword 借此仍能定位待验证用户。
|
||||||
func (s *Service) PendingPasswordUserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) {
|
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
|
return 0, false, nil
|
||||||
}
|
}
|
||||||
a, found, err := s.auths.ByAuthKey(ctx, authKeyID)
|
a, found, err := s.auths.ByAuthKey(ctx, authKeyID)
|
||||||
if err != nil || !found || !a.PasswordPending {
|
if err != nil || !found || !a.PasswordPending {
|
||||||
return 0, false, err
|
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)
|
_ = s.auths.Delete(ctx, authKeyID)
|
||||||
return 0, false, nil
|
return 0, false, nil
|
||||||
}
|
}
|
||||||
|
|
@ -359,13 +375,24 @@ func (s *Service) PendingPasswordUserID(ctx context.Context, authKeyID [8]byte)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompletePasswordSignIn 在两步验证通过后清除 password_pending,使 auth_key 转为完全授权。
|
// 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 {
|
if s == nil || s.auths == nil {
|
||||||
return 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
|
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
|
// This is where a 2FA account's sign-in actually finishes — finishSignIn
|
||||||
// deliberately skipped the welcome message while password_pending.
|
// deliberately skipped the welcome message while password_pending.
|
||||||
if a, found, err := s.auths.ByAuthKey(ctx, authKeyID); err == nil && found {
|
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{}) {
|
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
|
||||||
return domain.AuthKeyClientInfo{}, false, nil
|
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 {
|
if err != nil || !found {
|
||||||
return domain.AuthKeyClientInfo{}, found, err
|
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 {
|
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 {
|
if s.authKeys != nil {
|
||||||
key, found, err := s.authKeys.Get(ctx, auth.AuthKeyID)
|
key, found, err := s.authKeys.Get(ctx, auth.AuthKeyID)
|
||||||
if err != nil {
|
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) {
|
if errors.Is(err, store.ErrAuthKeyNotPermanent) {
|
||||||
return ErrAuthKeyPermEmpty
|
return ErrAuthKeyPermEmpty
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, domain.ErrAccountDeleted) || errors.Is(err, domain.ErrUserNotFound) {
|
||||||
|
return ErrSystemUserLoginForbidden
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -1535,17 +1579,19 @@ func (s *Service) passwordNeeded(ctx context.Context, userID int64) (bool, error
|
||||||
return found && settings.HasPassword, nil
|
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.
|
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.`
|
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) {
|
func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code string) (domain.Message, error) {
|
||||||
if s.messages == nil || s.dialogs == nil {
|
if s.messages == nil || s.dialogs == nil {
|
||||||
return domain.Message{}, nil
|
return domain.Message{}, nil
|
||||||
}
|
}
|
||||||
body := fmt.Sprintf(loginMessageTpl, code)
|
body := fmt.Sprintf(loginMessageTemplate(), code)
|
||||||
codeOffset := len("Login code: ")
|
codeOffset := len("Login code: ")
|
||||||
msg, err := s.messages.Create(ctx, domain.Message{
|
msg, err := s.messages.Create(ctx, domain.Message{
|
||||||
OwnerUserID: userID,
|
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()) {
|
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 {
|
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
|
// 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;
|
// to a still-live binding. It may never create or reclassify a protocol key;
|
||||||
// the caller normalizes durable retention to this handshake-authoritative
|
// the caller normalizes durable retention to this handshake-authoritative
|
||||||
// temp.ExpiresAt instead of trusting the client value.
|
// temp.ExpiresAt instead of trusting the client value.
|
||||||
if !found || temp.ExpiresAt <= int(time.Now().Unix()) {
|
if !pair.TemporaryFound || pair.Temporary.ExpiresAt <= int(time.Now().Unix()) {
|
||||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrTempAuthKeyEmpty
|
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrTempAuthKeyEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
|
if !pair.PermanentFound || pair.Permanent.ExpiresAt != 0 {
|
||||||
perm, found, err := s.authKeys.Get(ctx, permID)
|
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrEncryptedMessageInvalid
|
||||||
if err != nil {
|
|
||||||
return mtcrypto.BindAuthKeyInner{}, 0, err
|
|
||||||
}
|
|
||||||
if !found || perm.ExpiresAt != 0 {
|
|
||||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
inner, err := decryptBindAuthKeyInner(perm, binding.EncryptedMessage)
|
inner, err := decryptBindAuthKeyInner(pair.Permanent, binding.EncryptedMessage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
|
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrEncryptedMessageInvalid
|
||||||
}
|
}
|
||||||
if inner.Nonce != binding.Nonce ||
|
if inner.Nonce != binding.Nonce ||
|
||||||
inner.TempAuthKeyID != authKeyIDInt64(binding.TempAuthKeyID) ||
|
inner.TempAuthKeyID != authKeyIDInt64(binding.TempAuthKeyID) ||
|
||||||
inner.PermAuthKeyID != binding.PermAuthKeyID ||
|
inner.PermAuthKeyID != binding.PermAuthKeyID ||
|
||||||
inner.TempSessionID != sessionID ||
|
inner.TempSessionID != sessionID ||
|
||||||
inner.ExpiresAt != binding.ExpiresAt {
|
inner.ExpiresAt != binding.ExpiresAt {
|
||||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
|
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrEncryptedMessageInvalid
|
||||||
}
|
}
|
||||||
if temp.ExpiresAt <= int(time.Now().Unix()) {
|
if pair.Temporary.ExpiresAt <= int(time.Now().Unix()) {
|
||||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrTempAuthKeyEmpty
|
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 {
|
func (s *Service) classifyBindingStoreInvalid(ctx context.Context, binding domain.TempAuthKeyBinding) error {
|
||||||
if s == nil || s.authKeys == nil {
|
if s == nil || s.authKeys == nil {
|
||||||
return ErrEncryptedMessageInvalid
|
return ErrEncryptedMessageInvalid
|
||||||
}
|
}
|
||||||
temp, found, err := s.authKeys.Get(ctx, binding.TempAuthKeyID)
|
temp, found, err := s.authKeys.Revalidate(ctx, binding.TempAuthKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
|
||||||
t.Fatalf("encrypt bind message: %v", err)
|
t.Fatalf("encrypt bind message: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
|
_, err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
|
||||||
TempAuthKeyID: tempKey.ID,
|
TempAuthKeyID: tempKey.ID,
|
||||||
PermAuthKeyID: permKey.IntID(),
|
PermAuthKeyID: permKey.IntID(),
|
||||||
Nonce: nonce,
|
Nonce: nonce,
|
||||||
|
|
@ -59,7 +59,7 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
|
||||||
t.Fatalf("BindTempAuthKey valid message: %v", err)
|
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,
|
TempAuthKeyID: tempKey.ID,
|
||||||
PermAuthKeyID: permKey.IntID(),
|
PermAuthKeyID: permKey.IntID(),
|
||||||
Nonce: nonce,
|
Nonce: nonce,
|
||||||
|
|
@ -89,7 +89,7 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encrypt extended bind message: %v", err)
|
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,
|
TempAuthKeyID: tempKey.ID,
|
||||||
PermAuthKeyID: permKey.IntID(),
|
PermAuthKeyID: permKey.IntID(),
|
||||||
Nonce: nonce,
|
Nonce: nonce,
|
||||||
|
|
@ -120,17 +120,17 @@ func TestBindTempAuthKeyClassifiesExpiryWithoutDestroyingPermanentKey(t *testing
|
||||||
PermAuthKeyID: permKey.IntID(),
|
PermAuthKeyID: permKey.IntID(),
|
||||||
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
|
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)
|
t.Fatalf("expired protocol temp key err = %v, want ErrTempAuthKeyEmpty", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
request.TempAuthKeyID = testAuthKey(0x33).ID
|
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)
|
t.Fatalf("missing protocol temp key err = %v, want ErrTempAuthKeyEmpty", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
request.ExpiresAt = int(time.Now().Add(-time.Second).Unix())
|
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)
|
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) {
|
func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
users := memory.NewUserStore()
|
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) {
|
func verifyCodeForSignUp(t *testing.T, svc *Service, phone, hash, code string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
got, msg, needSignUp, err := svc.SignIn(context.Background(), domain.Authorization{}, phone, hash, code)
|
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)
|
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)
|
t.Fatalf("CompletePasswordSignIn: %v", err)
|
||||||
}
|
}
|
||||||
bound, found, err = svc.UserID(ctx, key)
|
bound, found, err = svc.UserID(ctx, key)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue