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_TIMEOUT=30s
|
||||
TELESRV_MTPROTO_RPC_GLOBAL_WORKERS=256
|
||||
TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=8192
|
||||
TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=32768
|
||||
TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES=536870912
|
||||
# Metadata-only rpc_result receipt budgets: global >= auth >= session. ACK deletes immediately;
|
||||
# 331s is only the no-ACK horizon. Payloads live solely in the logical-session outbound budget.
|
||||
|
|
@ -474,6 +474,10 @@ TELESRV_VERIFICATION_BOT_RATE_WINDOW=1m
|
|||
# rows. Interval must be positive; batch must be 1..500.
|
||||
TELESRV_VERIFICATION_NOTIFY_INTERVAL=15s
|
||||
TELESRV_VERIFICATION_NOTIFY_BATCH=50
|
||||
TELESRV_BROADCAST_WORKER_INTERVAL=3s
|
||||
TELESRV_BROADCAST_WORKER_LEASE=30s
|
||||
TELESRV_BROADCAST_MATERIALIZE_BATCH=200
|
||||
TELESRV_BROADCAST_DELIVERY_BATCH=50
|
||||
# Applications one applicant may keep open at once; 0 disables the cap, maximum
|
||||
# is 50.
|
||||
TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER=3
|
||||
|
|
|
|||
130
.github/workflows/build.yml
vendored
Normal file
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()
|
||||
|
||||
hs := hoststats.NewPoller(cfg.BlobDir)
|
||||
hs := hoststats.NewPoller(cfg.DiskStatsPath)
|
||||
go hs.Run(ctx, hostStatsPollInterval)
|
||||
|
||||
srv, err := newServer(cfg, newReadStore(pool), hs)
|
||||
|
|
@ -97,10 +97,10 @@ type uiConfig struct {
|
|||
Password string
|
||||
Token string
|
||||
SessionKey []byte
|
||||
// BlobDir is the local blob-storage root, reused only to pick which
|
||||
// filesystem the dashboard's disk-free reading statfs's -- irrelevant when
|
||||
// TELESRV_BLOB_BACKEND=s3, where disk space isn't the storage constraint.
|
||||
BlobDir string
|
||||
// DiskStatsPath points the dashboard host-disk sampler at the local path
|
||||
// that matters for the selected blob backend: permanent localfs storage or
|
||||
// the S3 upload spool.
|
||||
DiskStatsPath string
|
||||
// Permissions is the right set a panel session is issued with, from
|
||||
// TELESRV_ADMIN_UI_PERMISSIONS. The shipped default is the single wildcard
|
||||
// entry, so introducing the permission model never locks an operator out of a
|
||||
|
|
@ -163,14 +163,21 @@ func loadConfig() (uiConfig, error) {
|
|||
Password: appCfg.AdminUIPassword,
|
||||
Token: appCfg.AdminUIToken,
|
||||
SessionKey: sum[:],
|
||||
DiskStatsPath: dashboardDiskPath(appCfg),
|
||||
Permissions: appCfg.AdminUIPermissions,
|
||||
HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
|
||||
BlobDir: appCfg.BlobDir,
|
||||
IdentityDir: appCfg.IdentityDir,
|
||||
RepoRoot: repoRoot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func dashboardDiskPath(cfg config.Config) string {
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.BlobBackendKind), "s3") && strings.TrimSpace(cfg.BlobStagingDir) != "" {
|
||||
return cfg.BlobStagingDir
|
||||
}
|
||||
return cfg.BlobDir
|
||||
}
|
||||
|
||||
func adminAPIURL(addr string) string {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ import (
|
|||
// TELESRV_ADMIN_UI_PERMISSIONS and the ones the admin API enforces.
|
||||
const (
|
||||
permissionAll = "*"
|
||||
permissionPremiumManage = "premium.manage"
|
||||
permissionBotTokenRead = "bots.token.read"
|
||||
permissionVerificationReview = "verification.review"
|
||||
permissionVerificationRevoke = "verification.revoke"
|
||||
// Third-party bot verification. Deliberately not implied by the official
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/create-bot", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBotAPI)))
|
||||
mux.Handle("POST /api/actions/create-broadcast", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBroadcastAPI)))
|
||||
mux.Handle("POST /api/actions/delete-bot", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteBotAPI)))
|
||||
mux.Handle("POST /api/actions/export-bot-token", s.requireAuthAPI(http.HandlerFunc(s.handleExportBotTokenAPI)))
|
||||
mux.Handle("POST /api/actions/export-bot-token", s.requireAuthAPI(s.requirePermission(permissionBotTokenRead, http.HandlerFunc(s.handleExportBotTokenAPI))))
|
||||
mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
|
||||
mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
|
||||
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
|
||||
|
|
|
|||
|
|
@ -144,6 +144,53 @@ func TestModerationReadAPIDisablesBrowserCaching(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationRowJSONPreservesInt64AsDecimalStrings(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
raw, err := json.Marshal(AuthorizationRow{AuthKeyID: maxInt64, Hash: maxInt64})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal authorization row: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal authorization row: %v", err)
|
||||
}
|
||||
for _, field := range []string{"AuthKeyID", "Hash"} {
|
||||
if got[field] != "9223372036854775807" {
|
||||
t.Fatalf("authorization %s = %#v, want exact decimal string", field, got[field])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeSessionsBFFForwardsExactAuthorizationHash(t *testing.T) {
|
||||
const authorizationHash = int64(2361577175213625973)
|
||||
var got admin.RevokeSessionsRequest
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/accounts/revoke-sessions" || r.Header.Get("Authorization") != "Bearer secret" {
|
||||
t.Fatalf("upstream request path=%q authorization=%q", r.URL.Path, r.Header.Get("Authorization"))
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed", DryRun: got.DryRun})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/revoke-sessions", strings.NewReader(`{
|
||||
"reason":"precision regression","confirm":false,"user_id":1001,
|
||||
"hash":"2361577175213625973"
|
||||
}`))
|
||||
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleRevokeSessionsAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.Hash != authorizationHash || got.Actor != "operator" || !got.DryRun {
|
||||
t.Fatalf("forwarded revoke request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintCollectibleUsernameBFFForwardsActorAndTolerantScalars(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
var got admin.MintCollectibleUsernameRequest
|
||||
|
|
|
|||
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 type="module" crossorigin src="/assets/index-D8u51wND.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-hA2EpjuH.css">
|
||||
<script type="module" crossorigin src="/assets/index-CwTwvGWj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-0MvM-hpw.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
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": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -193,3 +193,20 @@ export function parseIDs(value: string, invalidMessage = "msg ids invalid"): num
|
|||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// toUnixSeconds reads a datetime-local input. Such an input carries no zone, so
|
||||
// the value parses as the operator's local time — which is the time they picked.
|
||||
// 0 means "empty or unparseable", which every caller treats as "not scheduled".
|
||||
export function toUnixSeconds(value: string): number {
|
||||
if (!value.trim()) return 0;
|
||||
const ms = new Date(value).getTime();
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
||||
}
|
||||
|
||||
// localInputValue formats a datetime-local default some seconds out, so a
|
||||
// scheduling form never opens on a value the server would reject as past.
|
||||
export function localInputValue(offsetSeconds: number): string {
|
||||
const at = new Date(Date.now() + offsetSeconds * 1000);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}:${pad(at.getMinutes())}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ export function ownerLabel(row: CollectibleUsernameRow, vaultLabel: string): str
|
|||
export function priceLabel(row: CollectibleUsernameRow): string {
|
||||
const base = formatCurrency(row.Amount, row.Currency);
|
||||
if (row.CryptoCurrency && row.CryptoAmount && row.CryptoAmount !== "0") {
|
||||
return `${base} (${formatCurrency(row.CryptoAmount, row.CryptoCurrency)})`;
|
||||
return `${formatCurrency(row.CryptoAmount, row.CryptoCurrency)} (${base})`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { Alert, PageFrame } from "./components/ui";
|
|||
// (cmd/telesrv-admin/security.go). "*" is the wildcard an operator configures for
|
||||
// a full-access session.
|
||||
export const permissionAll = "*";
|
||||
export const permissionPremiumManage = "premium.manage";
|
||||
export const permissionBotTokenRead = "bots.token.read";
|
||||
export const permissionVerificationReview = "verification.review";
|
||||
export const permissionVerificationRevoke = "verification.revoke";
|
||||
// Third-party verification is a separate mechanism and therefore a separate pair of
|
||||
|
|
|
|||
|
|
@ -425,3 +425,34 @@
|
|||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.secret-reveal {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
background: var(--warn-tint);
|
||||
border: 1px solid var(--warn-border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.secret-reveal-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--warn);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.secret-reveal-row { display: flex; align-items: center; gap: 10px; }
|
||||
.secret-reveal-value {
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-soft);
|
||||
letter-spacing: .12em;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,16 @@ func run(ctx context.Context, args []string) error {
|
|||
return runKeygen(args[1:])
|
||||
case "provision":
|
||||
return runProvision(ctx, args[1:])
|
||||
case "plan-dataset":
|
||||
return runPlanDataset(args[1:])
|
||||
case "seed":
|
||||
return runSeed(ctx, args[1:])
|
||||
case "snapshot":
|
||||
return runSnapshot(ctx, args[1:])
|
||||
case "mutate-offline":
|
||||
return runMutateOffline(ctx, args[1:])
|
||||
case "startup-run":
|
||||
return runStartup(ctx, args[1:])
|
||||
case "run":
|
||||
return runLoad(ctx, args[1:])
|
||||
case "summarize":
|
||||
|
|
@ -50,6 +60,231 @@ func run(ctx context.Context, args []string) error {
|
|||
}
|
||||
}
|
||||
|
||||
func runPlanDataset(args []string) error {
|
||||
flags := flag.NewFlagSet("plan-dataset", flag.ContinueOnError)
|
||||
out := flags.String("out", filepath.FromSlash("data/loadtest/dataset.json"), "owner-only immutable dataset plan")
|
||||
accounts := flags.Int("accounts", 1000, "logical primary accounts in the provisioned manifest")
|
||||
seed := flags.Int64("seed", 20260827, "deterministic topology and idempotency seed")
|
||||
privateFanout := flags.Int("private-fanout", -1, "outgoing private messages per account; -1 uses min(10, accounts-1)")
|
||||
hotGroups := flags.Int("hot-groups", 10, "hot supergroup count")
|
||||
hotMembers := flags.Int("hot-members", 0, "members per hot supergroup; 0 uses all accounts")
|
||||
hotHistory := flags.Int("hot-history", 100, "messages per hot supergroup")
|
||||
mediumGroups := flags.Int("medium-groups", 100, "medium supergroup count")
|
||||
mediumMembers := flags.Int("medium-members", 100, "members per medium supergroup")
|
||||
mediumHistory := flags.Int("medium-history", 30, "messages per medium supergroup")
|
||||
smallGroups := flags.Int("small-groups", 200, "small supergroup count")
|
||||
smallMembers := flags.Int("small-members", 20, "members per small supergroup")
|
||||
smallHistory := flags.Int("small-history", 10, "messages per small supergroup")
|
||||
heavyGroups := flags.Int("heavy-groups", 200, "heavy-user supergroup count")
|
||||
heavyAccounts := flags.Int("heavy-accounts", 100, "accounts included in every heavy supergroup")
|
||||
heavyHistory := flags.Int("heavy-history", 30, "messages per heavy supergroup")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("plan-dataset accepts no positional arguments")
|
||||
}
|
||||
if *hotMembers == 0 {
|
||||
*hotMembers = *accounts
|
||||
}
|
||||
if *privateFanout == -1 {
|
||||
*privateFanout = min(10, max(*accounts-1, 0))
|
||||
}
|
||||
cfg := loadharness.DatasetConfig{
|
||||
Accounts: *accounts, Seed: *seed, PrivateFanout: *privateFanout,
|
||||
HotGroups: *hotGroups, HotMembers: *hotMembers, HotHistory: *hotHistory,
|
||||
MediumGroups: *mediumGroups, MediumMembers: min(*mediumMembers, *accounts), MediumHistory: *mediumHistory,
|
||||
SmallGroups: *smallGroups, SmallMembers: min(*smallMembers, *accounts), SmallHistory: *smallHistory,
|
||||
HeavyGroups: *heavyGroups, HeavyAccounts: min(*heavyAccounts, *accounts), HeavyHistory: *heavyHistory,
|
||||
}
|
||||
if _, err := os.Stat(*out); err == nil {
|
||||
existing, loadErr := loadharness.LoadDataset(*out)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if existing.Config != cfg {
|
||||
return fmt.Errorf("refusing to replace existing dataset plan %s with different config", *out)
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "dataset plan already exists at %s hash=%s groups=%d private_messages=%d\n",
|
||||
*out, existing.PlanSHA256, len(existing.Groups), len(existing.PrivateEdges))
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
dataset, err := loadharness.PlanDataset(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := loadharness.WriteDataset(*out, dataset); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "dataset plan written to %s hash=%s groups=%d private_messages=%d\n",
|
||||
*out, dataset.PlanSHA256, len(dataset.Groups), len(dataset.PrivateEdges))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSeed(ctx context.Context, args []string) error {
|
||||
flags := flag.NewFlagSet("seed", flag.ContinueOnError)
|
||||
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
|
||||
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
|
||||
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
|
||||
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
|
||||
state := flags.String("state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "resumable seed journal")
|
||||
concurrency := flags.Int("concurrency", 8, "parallel account workers (max 64)")
|
||||
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one seed RPC")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("seed accepts no positional arguments")
|
||||
}
|
||||
result, err := loadharness.Seed(ctx, loadharness.SeedConfig{
|
||||
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||
DatasetPath: *dataset, SeedStatePath: *state, Concurrency: *concurrency, OperationTimeout: *operationTimeout,
|
||||
}, func(event loadharness.SeedEvent) {
|
||||
status := "ok"
|
||||
if event.Err != nil {
|
||||
status = "error"
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "seed phase=%s %d/%d account=%d status=%s\n",
|
||||
event.Phase, event.Completed, event.Total, event.Account, status)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "seed complete private_messages=%d supergroups=%d invited_members=%d group_messages=%d rich_state_accounts=%d state=%s\n",
|
||||
result.PrivateMessages, result.Groups, result.InvitedMembers, result.GroupMessages, result.RichStateAccounts, *state)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSnapshot(ctx context.Context, args []string) error {
|
||||
flags := flag.NewFlagSet("snapshot", flag.ContinueOnError)
|
||||
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
|
||||
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
|
||||
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
|
||||
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
|
||||
seedState := flags.String("seed-state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "completed seed journal")
|
||||
clientState := flags.String("client-state", filepath.FromSlash("data/loadtest/client-state.json"), "baseline account/dialog/PTS snapshot")
|
||||
concurrency := flags.Int("concurrency", 8, "parallel account workers (max 64)")
|
||||
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one snapshot RPC")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("snapshot accepts no positional arguments")
|
||||
}
|
||||
result, err := loadharness.SnapshotClientState(ctx, loadharness.SnapshotConfig{
|
||||
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||
DatasetPath: *dataset, SeedStatePath: *seedState, ClientStatePath: *clientState,
|
||||
Concurrency: *concurrency, OperationTimeout: *operationTimeout,
|
||||
}, func(event loadharness.SnapshotEvent) {
|
||||
status := "ok"
|
||||
if event.Resumed {
|
||||
status = "resumed"
|
||||
}
|
||||
if event.Err != nil {
|
||||
status = "error"
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "snapshot %d/%d account=%d status=%s\n", event.Completed, event.Total, event.Account, status)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "snapshot complete accounts=%d dialogs=%d channel_dialogs=%d client_state=%s\n",
|
||||
result.Accounts, result.Dialogs, result.Channels, *clientState)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMutateOffline(ctx context.Context, args []string) error {
|
||||
flags := flag.NewFlagSet("mutate-offline", flag.ContinueOnError)
|
||||
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
|
||||
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
|
||||
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
|
||||
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
|
||||
seedState := flags.String("seed-state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "completed seed journal")
|
||||
clientState := flags.String("client-state", filepath.FromSlash("data/loadtest/client-state.json"), "immutable old account/channel PTS snapshot")
|
||||
mutationState := flags.String("mutation-state", filepath.FromSlash("data/loadtest/offline-mutation-state.json"), "resumable offline mutation journal")
|
||||
concurrency := flags.Int("concurrency", 8, "parallel writer accounts (max 64)")
|
||||
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one mutation RPC")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("mutate-offline accepts no positional arguments")
|
||||
}
|
||||
result, err := loadharness.MutateOffline(ctx, loadharness.MutateOfflineConfig{
|
||||
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||
DatasetPath: *dataset, SeedStatePath: *seedState, ClientStatePath: *clientState,
|
||||
MutationStatePath: *mutationState, Concurrency: *concurrency, OperationTimeout: *operationTimeout,
|
||||
}, func(event loadharness.MutationEvent) {
|
||||
status := "ok"
|
||||
if event.Err != nil {
|
||||
status = "error"
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "mutate phase=%s %d/%d account=%d status=%s\n",
|
||||
event.Phase, event.Completed, event.Total, event.Account, status)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "offline mutation complete private_messages=%d dirty_channels=%d channel_messages=%d edited=%d deleted=%d pinned=%d state=%s\n",
|
||||
result.PrivateMessages, result.DirtyChannels, result.ChannelMessages, result.Edited, result.Deleted, result.Pinned, *mutationState)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runStartup(ctx context.Context, args []string) error {
|
||||
flags := flag.NewFlagSet("startup-run", flag.ContinueOnError)
|
||||
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
|
||||
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
|
||||
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
|
||||
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
|
||||
seedState := flags.String("seed-state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "completed seed journal")
|
||||
clientState := flags.String("client-state", filepath.FromSlash("data/loadtest/client-state.json"), "immutable old account/channel PTS snapshot")
|
||||
mutationState := flags.String("mutation-state", filepath.FromSlash("data/loadtest/offline-mutation-state.json"), "completed offline mutation journal")
|
||||
report := flags.String("report", filepath.FromSlash("data/loadtest/startup-report.json"), "startup correctness and latency report")
|
||||
events := flags.String("events", filepath.FromSlash("data/loadtest/startup-events.ndjson"), "periodic owner-only startup and server metric evidence")
|
||||
serverMetrics := flags.String("server-metrics", "http://127.0.0.1:6060/metrics", "server metrics URL; empty disables")
|
||||
profile := flags.String("profile", loadharness.StartupProfileTDesktopReturningV1, "startup workload: tdesktop-cold-returning-v1 or tdlib-returning-v1")
|
||||
startOrder := flags.String("start-order", loadharness.StartupOrderShuffled, "account launch order: shuffled or account-index")
|
||||
startOrderSeed := flags.Int64("start-order-seed", 0, "deterministic shuffled launch seed; 0 uses the dataset seed")
|
||||
accounts := flags.Int("accounts", 0, "limit first N accounts; 0 uses the complete dataset")
|
||||
ramp := flags.Duration("ramp", 30*time.Second, "connection start ramp duration")
|
||||
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one startup RPC")
|
||||
sampleInterval := flags.Duration("sample-interval", 2*time.Second, "server resource sampling interval")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("startup-run accepts no positional arguments")
|
||||
}
|
||||
result, err := loadharness.StartupRun(ctx, loadharness.StartupRunConfig{
|
||||
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||
DatasetPath: *dataset, SeedStatePath: *seedState, ClientStatePath: *clientState,
|
||||
MutationStatePath: *mutationState, ReportPath: *report, EventsPath: *events, ServerMetricsURL: *serverMetrics,
|
||||
Profile: *profile, StartOrder: *startOrder, StartOrderSeed: *startOrderSeed,
|
||||
AccountLimit: *accounts, RampDuration: *ramp, OperationTimeout: *operationTimeout,
|
||||
SampleInterval: *sampleInterval,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printStartupSummary(result)
|
||||
if !result.Pass {
|
||||
return fmt.Errorf("startup acceptance failed; see %s", *report)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printStartupSummary(report *loadharness.StartupRunReport) {
|
||||
fmt.Fprintf(os.Stdout, "pass=%v business_ready=%d/%d dialogs=%d channel_dialogs=%d account_diff_calls=%d channel_diff_calls=%d channel_full=%d channel_too_long=%d channel_empty=%d\n",
|
||||
report.Pass, report.BusinessReady, report.ExpectedAccounts, report.DialogsObserved, report.ChannelDialogs,
|
||||
report.AccountDifference.Calls, report.ChannelDifference.Calls, report.ChannelDifference.Full,
|
||||
report.ChannelDifference.TooLong, report.ChannelDifference.Empty)
|
||||
for _, failure := range report.Failures {
|
||||
fmt.Fprintln(os.Stdout, "failure:", failure)
|
||||
}
|
||||
}
|
||||
|
||||
func runKeygen(args []string) error {
|
||||
flags := flag.NewFlagSet("keygen", flag.ContinueOnError)
|
||||
path := flags.String("out", filepath.FromSlash("data/loadtest/session.key"), "owner-only session encryption key file")
|
||||
|
|
@ -78,7 +313,7 @@ func runProvision(ctx context.Context, args []string) error {
|
|||
accounts := flags.Int("accounts", 450, "unique accounts")
|
||||
extraDevices := flags.Int("extra-devices", 50, "accounts receiving a second independent session")
|
||||
concurrency := flags.Int("concurrency", 8, "parallel provisioning workers (max 64)")
|
||||
phonePrefix := flags.String("phone-prefix", "+155500", "E.164 prefix followed by a six-digit account index")
|
||||
phonePrefix := flags.String("phone-prefix", loadharness.DefaultPhonePrefix, "possible reserved NANP prefix followed by a six-digit account index")
|
||||
firstName := flags.String("first-name-prefix", "Load", "generated first-name prefix")
|
||||
obfuscated := flags.Bool("obfuscated", true, "use TDesktop-like Obfuscated2 + abridged transport")
|
||||
pfs := flags.Bool("pfs", true, "bind temporary auth keys using PFS")
|
||||
|
|
@ -129,12 +364,17 @@ func runLoad(ctx context.Context, args []string) error {
|
|||
events := flags.String("events", filepath.FromSlash("data/loadtest/events.ndjson"), "periodic NDJSON evidence")
|
||||
fileFixture := flags.String("file-fixture", "", "reusable fixture JSON; empty stores beside manifest")
|
||||
serverMetrics := flags.String("server-metrics", "http://127.0.0.1:6060/metrics", "server metrics URL; empty disables")
|
||||
startOrder := flags.String("start-order", loadharness.StartupOrderShuffled, "session launch order: shuffled or account-index")
|
||||
startOrderSeed := flags.Int64("start-order-seed", 20260827, "deterministic shuffled launch seed")
|
||||
sessions := flags.Int("sessions", 0, "limit selected sessions; 0 uses all")
|
||||
duration := flags.Duration("duration", 30*time.Minute, "sustained load duration")
|
||||
recovery := flags.Duration("recovery", 7*time.Minute, "post-disconnect reclamation observation")
|
||||
ramp := flags.Duration("ramp", 2*time.Minute, "connection ramp duration")
|
||||
rpcInterval := flags.Duration("rpc-interval", 5*time.Second, "per-session background RPC interval")
|
||||
messageInterval := flags.Duration("message-interval", 30*time.Second, "per-primary-session message interval; negative disables")
|
||||
messageRate := flags.Float64("message-rate", 0, "aggregate fixed arrival rate in messages/second; use with message-interval=-1")
|
||||
messageQueue := flags.Int("message-queue", 8, "bounded pending sends per primary session for fixed-rate workload")
|
||||
deliverySettle := flags.Duration("delivery-settle", 10*time.Second, "maximum live-delivery settle time before final updates.getDifference reconciliation")
|
||||
fileInterval := flags.Duration("file-interval", time.Minute, "per-session upload.getFile interval")
|
||||
fileSize := flags.Int("file-size", 4<<20, "generated shared download fixture bytes; 0 disables")
|
||||
fileChunk := flags.Int("file-chunk", 1<<20, "upload.getFile bytes per request (max 1MiB)")
|
||||
|
|
@ -155,8 +395,10 @@ func runLoad(ctx context.Context, args []string) error {
|
|||
result, err := loadharness.Run(ctx, loadharness.RunConfig{
|
||||
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||
ReportPath: *report, EventsPath: *events, FileFixturePath: *fileFixture, ServerMetricsURL: *serverMetrics,
|
||||
StartOrder: *startOrder, StartOrderSeed: *startOrderSeed,
|
||||
SessionLimit: *sessions, Duration: *duration, RecoveryDuration: *recovery, RampDuration: *ramp,
|
||||
RPCInterval: *rpcInterval, MessageInterval: *messageInterval, SampleInterval: *sampleInterval,
|
||||
RPCInterval: *rpcInterval, MessageInterval: *messageInterval, MessageRate: *messageRate,
|
||||
MessageQueueDepth: *messageQueue, DeliverySettle: *deliverySettle, SampleInterval: *sampleInterval,
|
||||
FileInterval: *fileInterval, FileSizeBytes: *fileSize, FileChunkBytes: *fileChunk, SetupTimeout: *setupTimeout,
|
||||
OperationTimeout: *operationTimeout,
|
||||
OfflineFraction: *offlineFraction, OfflineAt: *offlineAt, OfflineFor: *offlineFor,
|
||||
|
|
@ -183,6 +425,25 @@ func runSummarize(args []string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var shape struct {
|
||||
BusinessReady *int `json:"business_ready"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &shape); err != nil {
|
||||
return err
|
||||
}
|
||||
if shape.BusinessReady != nil {
|
||||
var report loadharness.StartupRunReport
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&report); err != nil {
|
||||
return err
|
||||
}
|
||||
printStartupSummary(&report)
|
||||
if !report.Pass {
|
||||
return errors.New("startup report did not pass")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var report loadharness.RunReport
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
|
@ -197,9 +458,9 @@ func runSummarize(args []string) error {
|
|||
}
|
||||
|
||||
func printSummary(report *loadharness.RunReport) {
|
||||
fmt.Fprintf(os.Stdout, "pass=%v sessions=%d peak_ready=%d reconnects=%d disconnects=%d flood_waits=%d fatal_errors=%d\n",
|
||||
fmt.Fprintf(os.Stdout, "pass=%v sessions=%d peak_ready=%d reconnects=%d disconnects=%d flood_waits=%d fatal_errors=%d scheduled=%d delivered=%d missing=%d\n",
|
||||
report.Pass, report.ExpectedSessions, report.PeakReadySessions, report.Reconnects, report.Disconnects,
|
||||
totalFloodWaits(report), report.WorkerFatalErrors)
|
||||
totalFloodWaits(report), report.WorkerFatalErrors, report.MessageScheduled, report.Delivery.Delivered, report.Delivery.Missing)
|
||||
for _, failure := range report.Failures {
|
||||
fmt.Fprintln(os.Stdout, "failure:", failure)
|
||||
}
|
||||
|
|
@ -214,12 +475,17 @@ func totalFloodWaits(report *loadharness.RunReport) uint64 {
|
|||
}
|
||||
|
||||
func usageError() error {
|
||||
return errors.New("expected one of: keygen, provision, run, summarize, help")
|
||||
return errors.New("expected one of: keygen, provision, plan-dataset, seed, snapshot, mutate-offline, startup-run, run, summarize, help")
|
||||
}
|
||||
|
||||
const usageText = `telesrv-load commands:
|
||||
keygen generate an owner-only AES-256 session key
|
||||
provision create accounts and encrypted sessions through real MTProto auth
|
||||
plan-dataset create an immutable real-data topology with stable RPC identities
|
||||
seed materialize private dialogs, supergroups and messages via real RPCs
|
||||
snapshot save paginated real dialogs and old account/channel PTS cursors
|
||||
mutate-offline create account/channel gaps while preserving the old cursors
|
||||
startup-run restore old cursors and measure dialogs/difference business readiness
|
||||
run execute sustained real-client load, offline recovery and reclamation
|
||||
summarize print the acceptance summary from a JSON report
|
||||
|
||||
|
|
|
|||
109
cmd/telesrv-update/main.go
Normal file
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/signal"
|
||||
"runtime"
|
||||
runtimemetrics "runtime/metrics"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
|
@ -60,6 +61,7 @@ import (
|
|||
"telesrv/internal/app/userprojection"
|
||||
"telesrv/internal/app/users"
|
||||
verificationapp "telesrv/internal/app/verification"
|
||||
welcomemessagesapp "telesrv/internal/app/welcomemessages"
|
||||
"telesrv/internal/botapi"
|
||||
"telesrv/internal/config"
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -79,6 +81,7 @@ import (
|
|||
"telesrv/internal/store/redisstore"
|
||||
"telesrv/internal/telegramloginhttp"
|
||||
"telesrv/internal/turnsrv"
|
||||
"telesrv/internal/updatecdn"
|
||||
"telesrv/internal/web"
|
||||
)
|
||||
|
||||
|
|
@ -273,8 +276,9 @@ func startDebugServer(ctx context.Context, addr string, metricsHandler http.Hand
|
|||
func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
|
||||
var mem runtime.MemStats
|
||||
runtime.ReadMemStats(&mem)
|
||||
return []obsmetrics.GaugeSample{
|
||||
samples := []obsmetrics.GaugeSample{
|
||||
{Name: "telesrv_go_goroutines", Value: float64(runtime.NumGoroutine())},
|
||||
{Name: "telesrv_go_scheduler_busy_seconds", Value: goSchedulerBusySeconds()},
|
||||
{Name: "telesrv_go_heap_alloc_bytes", Value: float64(mem.HeapAlloc)},
|
||||
{Name: "telesrv_go_heap_inuse_bytes", Value: float64(mem.HeapInuse)},
|
||||
{Name: "telesrv_go_heap_objects", Value: float64(mem.HeapObjects)},
|
||||
|
|
@ -283,6 +287,28 @@ func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
|
|||
{Name: "telesrv_go_gc_cycles", Value: float64(mem.NumGC)},
|
||||
{Name: "telesrv_go_gc_pause_seconds", Value: time.Duration(mem.PauseTotalNs).Seconds()},
|
||||
}
|
||||
if value, ok := processCPUSeconds(); ok {
|
||||
samples = append(samples, obsmetrics.GaugeSample{Name: "telesrv_process_cpu_seconds", Value: value})
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
// goSchedulerBusySeconds is a Go scheduler-class estimate. The runtime
|
||||
// documentation explicitly warns that CPU-class values are overestimates and
|
||||
// are not comparable to operating-system process CPU time, so capacity reports
|
||||
// use telesrv_process_cpu_seconds instead.
|
||||
func goSchedulerBusySeconds() float64 {
|
||||
samples := []runtimemetrics.Sample{
|
||||
{Name: "/cpu/classes/total:cpu-seconds"},
|
||||
{Name: "/cpu/classes/idle:cpu-seconds"},
|
||||
}
|
||||
runtimemetrics.Read(samples)
|
||||
total := samples[0].Value.Float64()
|
||||
idle := samples[1].Value.Float64()
|
||||
if total <= idle {
|
||||
return 0
|
||||
}
|
||||
return total - idle
|
||||
}
|
||||
|
||||
func mtprotoRuntimeGaugeSamples(snapshot mtprotoedge.RuntimeSnapshot) []obsmetrics.GaugeSample {
|
||||
|
|
@ -303,6 +329,15 @@ func mtprotoRuntimeGaugeSamples(snapshot mtprotoedge.RuntimeSnapshot) []obsmetri
|
|||
{Name: "telesrv_mtproto_inbound_rpc_ready_connections", Value: float64(snapshot.InboundRPCReadyConnections)},
|
||||
{Name: "telesrv_mtproto_inbound_rpc_task_limit", Value: float64(snapshot.InboundRPCMaxTasks)},
|
||||
{Name: "telesrv_mtproto_inbound_rpc_byte_limit", Value: float64(snapshot.InboundRPCMaxBytes)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_workers", Value: float64(snapshot.RPCDeliveryHookWorkers)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_capacity", Value: float64(snapshot.RPCDeliveryHookCapacity)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_reserved", Value: float64(snapshot.RPCDeliveryHookReserved)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_queued", Value: float64(snapshot.RPCDeliveryHookQueued)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_running", Value: float64(snapshot.RPCDeliveryHookRunning)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_completed_total", Value: float64(snapshot.RPCDeliveryHookCompleted)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_rejected_total", Value: float64(snapshot.RPCDeliveryHookRejected)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_panics_total", Value: float64(snapshot.RPCDeliveryHookPanics)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_duration_seconds_total", Value: snapshot.RPCDeliveryHookDurationSeconds},
|
||||
{Name: "telesrv_mtproto_inbound_frame_bytes", Value: float64(snapshot.InboundFrameBytes)},
|
||||
{Name: "telesrv_mtproto_inbound_frame_byte_limit", Value: float64(snapshot.InboundFrameMaxBytes)},
|
||||
{Name: "telesrv_mtproto_outbound_tracked_bytes", Labels: []obsmetrics.Label{{Name: "kind", Value: "body"}}, Value: float64(snapshot.OutboundTrackedBytes)},
|
||||
|
|
@ -445,8 +480,10 @@ type rpcProjectionVerificationNotifier struct {
|
|||
invalidator interface {
|
||||
InvalidateRPCProjectionReadModelForUser(userID int64)
|
||||
InvalidateRPCProjectionReadModelForChannel(channelID int64)
|
||||
InvalidatePeerIdentityReadModel(domain.Peer)
|
||||
}
|
||||
users storepkg.UserCache
|
||||
peerIdentity bool
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
|
|
@ -454,6 +491,9 @@ func (n rpcProjectionVerificationNotifier) NotifyPeerVerified(ctx context.Contex
|
|||
if n.invalidator == nil {
|
||||
return nil
|
||||
}
|
||||
if n.peerIdentity {
|
||||
n.invalidator.InvalidatePeerIdentityReadModel(peer)
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
n.invalidator.InvalidateRPCProjectionReadModelForUser(peer.ID)
|
||||
|
|
@ -570,6 +610,15 @@ func run(logger *zap.Logger) error {
|
|||
zap.Bool("schema_dirty", migrationStatus.Dirty),
|
||||
zap.Bool("schema_empty", migrationStatus.Empty),
|
||||
)
|
||||
blobRuntimeLock, err := postgres.AcquireBlobRuntimeLock(ctx, cfg.PostgresDSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire blob runtime lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := blobRuntimeLock.Close(); err != nil {
|
||||
logger.Error("release blob runtime lock", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
pool, err := postgres.Open(ctx, cfg.PostgresDSN,
|
||||
postgres.WithMaxConns(cfg.PostgresMaxConns),
|
||||
postgres.WithMinConns(cfg.PostgresMinConns),
|
||||
|
|
@ -649,6 +698,7 @@ func run(logger *zap.Logger) error {
|
|||
if cfg.TelegramLoginEnabled {
|
||||
telegramLoginHTTPHandler, err = telegramloginhttp.NewHandler(telegramloginhttp.Config{
|
||||
Service: telegramLoginService, Tokens: telegramLoginIDTokens,
|
||||
BotUsernames: postgres.NewUserStore(pool),
|
||||
Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName,
|
||||
Logger: logger.Named("telegram-login-http"), TrustedProxyCIDRs: cfg.TelegramLoginTrustedProxyCIDRs,
|
||||
AllowHTTP: cfg.TelegramLoginAllowHTTP,
|
||||
|
|
@ -662,51 +712,157 @@ func run(logger *zap.Logger) error {
|
|||
}
|
||||
|
||||
authKeyStore := postgres.NewAuthKeyStore(pool)
|
||||
authKeyGetBatchStore, err := postgres.NewBatchedAuthKeyStore(
|
||||
authKeyStore,
|
||||
postgres.AuthKeyGetBatchConfig{
|
||||
MaxSize: cfg.AuthKeyGetBatchMax, MaxWait: cfg.AuthKeyGetBatchWait,
|
||||
QueueSize: cfg.AuthKeyGetBatchQueue, QueryTimeout: cfg.AuthKeyGetBatchTimeout,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer authKeyGetBatchStore.Close()
|
||||
authKeySessionLayerStore, err := postgres.NewBatchedAuthKeySessionLayerStore(
|
||||
authKeyStore,
|
||||
postgres.AuthKeySessionLayerBatchConfig{
|
||||
MaxSize: cfg.LayerAdvanceBatchMax, MaxWait: cfg.LayerAdvanceBatchWait,
|
||||
QueueSize: cfg.LayerAdvanceBatchQueue, QueryTimeout: cfg.LayerAdvanceBatchTimeout,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer authKeySessionLayerStore.Close()
|
||||
userStore := postgres.NewUserStore(pool)
|
||||
authzStore := postgres.NewAuthorizationStore(pool)
|
||||
adminStore := postgres.NewAdminStore(pool)
|
||||
updateStateStore := postgres.NewUpdateStateStore(pool)
|
||||
updateEventStore := postgres.NewUpdateEventStore(pool, postgres.WithUpdateEventLogger(logger.Named("store").Named("updates")))
|
||||
phoneChangeStore := postgres.NewPhoneChangeStore(pool)
|
||||
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(postgres.NewReadModelVersionStore(pool), 0, 0)
|
||||
readModelVersionBatchStore, err := storepkg.NewBatchedReadModelVersionStore(
|
||||
postgres.NewReadModelVersionStore(pool),
|
||||
storepkg.ReadModelVersionBatchConfig{
|
||||
MaxKeys: cfg.ReadModelVersionBatchMaxKeys, MaxWait: cfg.ReadModelVersionBatchWait,
|
||||
QueueSize: cfg.ReadModelVersionBatchQueue, QueryTimeout: cfg.ReadModelVersionBatchTimeout,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer readModelVersionBatchStore.Close()
|
||||
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(
|
||||
readModelVersionBatchStore,
|
||||
0,
|
||||
cfg.ReadModelVersionCacheMaxEntries,
|
||||
)
|
||||
dialogListSnapshotCache := redisstore.NewDialogListSnapshotCache(rdb, cfg.DialogListSnapshotRedisTTL)
|
||||
activeChannelIDsPageCache := redisstore.NewActiveChannelIDsPageCache(rdb, cfg.ActiveChannelIDsRedisTTL)
|
||||
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout))
|
||||
bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool)
|
||||
bootstrapUpdateStore, err := postgres.NewBatchedBootstrapUpdateJobStore(
|
||||
postgres.NewBootstrapUpdateJobStore(pool),
|
||||
postgres.BootstrapReadyBatchConfig{
|
||||
MaxSize: cfg.BootstrapReadyBatchMax, MaxWait: cfg.BootstrapReadyBatchWait,
|
||||
QueueSize: cfg.BootstrapReadyBatchQueue, QueryTimeout: cfg.BootstrapReadyBatchTimeout,
|
||||
Metrics: metricRegistry,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer bootstrapUpdateStore.Close()
|
||||
botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool)
|
||||
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
|
||||
ephemeralStore := redisstore.NewEphemeralMessageStore(rdb)
|
||||
ephemeralReportStore := postgres.NewEphemeralReportStore(pool)
|
||||
welcomeMessageStore := postgres.NewWelcomeMessageStore(pool)
|
||||
moderationReportStore := postgres.NewModerationReportStore(pool)
|
||||
authDeliveryReportStore := postgres.NewAuthDeliveryReportStore(pool)
|
||||
clientTelemetryStore := postgres.NewClientTelemetryStore(pool)
|
||||
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
|
||||
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
|
||||
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
|
||||
secretChatIDAllocator := redisstore.NewSecretChatIDAllocator(rdb, postgres.NewSecretChatIDCounterSource(pool))
|
||||
contactStore := userprojection.NewCachedContactStore(postgres.NewContactStore(pool), 0)
|
||||
reverseContactStore, err := storepkg.NewBatchedReverseContactStore(
|
||||
postgres.NewContactStore(pool),
|
||||
storepkg.ReverseContactBatchConfig{
|
||||
MaxPairs: cfg.ContactReverseBatchMaxPairs, MaxWait: cfg.ContactReverseBatchWait,
|
||||
QueueSize: cfg.ContactReverseBatchQueue, QueryTimeout: cfg.ContactReverseBatchTimeout,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reverseContactStore.Close()
|
||||
contactStore := userprojection.NewCachedContactStoreWithMaxViewers(
|
||||
reverseContactStore,
|
||||
0,
|
||||
cfg.ContactSnapshotCacheMaxViewers,
|
||||
)
|
||||
dialogStore := postgres.NewDialogStore(pool)
|
||||
chatlistStore := postgres.NewChatlistStore(pool)
|
||||
messageStore := postgres.NewMessageStore(pool,
|
||||
postgres.WithMessageAllocators(boxIDAllocator),
|
||||
postgres.WithMessageLogger(logger.Named("store").Named("messages")))
|
||||
broadcastStore := postgres.NewBroadcastStore(pool)
|
||||
broadcastService := broadcastapp.NewService(broadcastStore,
|
||||
broadcastapp.WithMessageSender(messageStore),
|
||||
broadcastapp.WithLogger(logger.Named("broadcast")))
|
||||
// 共享频道行/成员缓存 + 统一 read-model LISTEN/NOTIFY 实时失效:消除高频「逐 RPC
|
||||
// 解析频道/成员」在客户端重连同步突发里重复读同一行的放大。
|
||||
channelRowCache := postgres.NewChannelRowCache(cfg.ChannelRowCacheMaxEntries)
|
||||
channelTopMessageCache := postgres.NewChannelTopMessageCache(cfg.ChannelTopMessageCacheMaxEntries)
|
||||
channelMemberCache := postgres.NewChannelMemberCache(cfg.ChannelMemberCacheMaxEntries)
|
||||
channelDialogCache := postgres.NewChannelDialogCache(cfg.ChannelDialogCacheMaxEntries)
|
||||
channelDifferenceCache := postgres.NewChannelDifferenceBaseCache(
|
||||
cfg.ChannelDifferenceCacheMaxEntries,
|
||||
cfg.ChannelDifferenceCacheMaxBytes,
|
||||
cfg.ChannelDifferenceCacheTTL,
|
||||
)
|
||||
channelBoostCache := postgres.NewChannelBoostCache(cfg.ChannelBoostCacheMaxEntries, cfg.ChannelBoostCacheTTL)
|
||||
channelStore := postgres.NewChannelStore(pool,
|
||||
postgres.WithChannelAllocators(channelIDAllocator, channelMessageIDAllocator),
|
||||
postgres.WithChannelLogger(logger.Named("store").Named("channels")),
|
||||
postgres.WithChannelRowCache(channelRowCache),
|
||||
postgres.WithChannelTopMessageCache(channelTopMessageCache),
|
||||
postgres.WithChannelMemberCache(channelMemberCache),
|
||||
postgres.WithChannelDialogCache(channelDialogCache),
|
||||
postgres.WithChannelDifferenceBaseCache(channelDifferenceCache),
|
||||
postgres.WithChannelBoostCache(channelBoostCache))
|
||||
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator)
|
||||
activeChannelIDsPageBatcher, err := postgres.NewActiveChannelIDsPageBatcher(
|
||||
channelStore,
|
||||
postgres.ActiveChannelIDsBatchConfig{
|
||||
MaxSize: cfg.ActiveChannelIDsBatchMax, MaxWait: cfg.ActiveChannelIDsBatchWait,
|
||||
QueueSize: cfg.ActiveChannelIDsBatchQueue, QueryTimeout: cfg.ActiveChannelIDsBatchTimeout,
|
||||
Metrics: metricRegistry,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer activeChannelIDsPageBatcher.Close()
|
||||
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
|
||||
snapshot := channelDifferenceCache.Snapshot()
|
||||
return []obsmetrics.GaugeSample{
|
||||
{Name: "telesrv_channel_difference_cache_entries", Value: float64(snapshot.Entries)},
|
||||
{Name: "telesrv_channel_difference_cache_weight_bytes", Value: float64(snapshot.Weight)},
|
||||
{Name: "telesrv_channel_difference_cache_hits", Value: float64(snapshot.Hits)},
|
||||
{Name: "telesrv_channel_difference_cache_misses", Value: float64(snapshot.Misses)},
|
||||
{Name: "telesrv_channel_difference_cache_loads", Value: float64(snapshot.Loads)},
|
||||
{Name: "telesrv_channel_difference_cache_load_errors", Value: float64(snapshot.LoadErrors)},
|
||||
}
|
||||
})
|
||||
communityCatalogCache := postgres.NewCommunityCatalogCache()
|
||||
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator,
|
||||
postgres.WithCommunityCatalogCache(communityCatalogCache))
|
||||
pollStore := postgres.NewPollStore(pool)
|
||||
mediaStore := postgres.NewMediaStore(pool)
|
||||
// 头像投影缓存:所有 projector 共用一层短 TTL owner→头像缓存,消除高频「返回用户」RPC
|
||||
// 每次投影对每批 owner 固定 2 次的 CurrentProfilePhotosKind PG 查询。
|
||||
cachedPhotos := userprojection.NewCachedPhotoProvider(mediaStore, userprojection.DefaultPhotoCacheTTL)
|
||||
// 头像投影缓存:所有 projector 共用 owner→头像正/负 LRU。profile_photo NOTIFY
|
||||
// 精确失效负责正常新鲜度,长 TTL 只覆盖漏通知,避免登录 ramp 周期性重查稳定负值。
|
||||
cachedPhotos := userprojection.NewCachedPhotoProviderWithMaxEntries(
|
||||
mediaStore,
|
||||
cfg.ProfilePhotoCacheTTL,
|
||||
cfg.ProfilePhotoCacheMaxEntries,
|
||||
)
|
||||
privacyStore := privacyapp.NewCachedPrivacyStore(postgres.NewPrivacyStore(pool), 0)
|
||||
storyStore := postgres.NewStoryStore(pool)
|
||||
// Transient upload-part scratch storage always stays on local disk
|
||||
|
|
@ -902,6 +1058,11 @@ func run(logger *zap.Logger) error {
|
|||
Commands: adminStore,
|
||||
Restrictions: adminStore,
|
||||
})
|
||||
userProjectionFacts := userprojection.NewDurableUserProjectionFacts(
|
||||
adminService,
|
||||
readModelVersionStore,
|
||||
cfg.UserProjectionFactCacheMaxEntries,
|
||||
)
|
||||
storageRetentionMaxAge := cfg.StorageRetentionMaxAge
|
||||
if !cfg.StorageRetentionEnable {
|
||||
storageRetentionMaxAge = 0
|
||||
|
|
@ -932,7 +1093,7 @@ func run(logger *zap.Logger) error {
|
|||
contactsService := contacts.NewService(contactStore, userStore).Configure(
|
||||
contacts.WithPhotoProvider(cachedPhotos),
|
||||
contacts.WithPrivacyEvaluator(privacyService),
|
||||
contacts.WithAccountFreezeProvider(adminService),
|
||||
contacts.WithAccountFreezeProvider(userProjectionFacts),
|
||||
contacts.WithReadModelVersions(readModelVersionStore),
|
||||
contacts.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification),
|
||||
)
|
||||
|
|
@ -1036,6 +1197,33 @@ func run(logger *zap.Logger) error {
|
|||
botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow),
|
||||
botsapp.WithPublicBaseURL(cfg.PublicBaseURL),
|
||||
botsapp.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification))
|
||||
// The built-in ChatBot and StickersBot are seeded with the default product
|
||||
// name in their bio (users.about) and description (bots.description). Align
|
||||
// them with the active branding on startup so the seeded "telesrv" text is
|
||||
// replaced. SetBotInfo writes both fields; the sync is a no-op when the text
|
||||
// already matches.
|
||||
for _, botID := range []int64{domain.ChatBotUserID, domain.StickersBotUserID} {
|
||||
var wantAbout, wantDesc string
|
||||
switch botID {
|
||||
case domain.ChatBotUserID:
|
||||
wantAbout = domain.ChatBotDescription()
|
||||
wantDesc = wantAbout
|
||||
case domain.StickersBotUserID:
|
||||
wantAbout = domain.StickersBotDescription()
|
||||
wantDesc = wantAbout
|
||||
}
|
||||
if _, curAbout, curDesc, err := botsService.GetBotInfo(ctx, botID); err == nil && curAbout == wantAbout && curDesc == wantDesc {
|
||||
continue
|
||||
}
|
||||
if _, err := botsService.SetBotInfo(ctx, botID, domain.BotInfoUpdate{
|
||||
SetAbout: true,
|
||||
About: wantAbout,
|
||||
SetDescription: true,
|
||||
Description: wantDesc,
|
||||
}); err != nil {
|
||||
logger.Warn("sync bot branding", zap.Int64("bot", botID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
groupCallStore := postgres.NewGroupCallStore(pool)
|
||||
groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL))
|
||||
// 群通话媒体面:内嵌 pion SFU(M1+)。SFU 的 liveness reporter 把媒体面存活
|
||||
|
|
@ -1120,7 +1308,7 @@ func run(logger *zap.Logger) error {
|
|||
// 私聊端对端加密(Secret Chat)握手状态机 + qts 投递队列(盲中继)。
|
||||
secretChatStore := postgres.NewSecretChatStore(pool)
|
||||
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
|
||||
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator)
|
||||
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore)
|
||||
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
|
||||
// 同属进程内一次性凭据,不跨实例)。
|
||||
passkeyStore := postgres.NewPasskeyStore(pool)
|
||||
|
|
@ -1129,7 +1317,7 @@ func run(logger *zap.Logger) error {
|
|||
passkeyapp.WithAllowedOrigins(cfg.PasskeyAllowedOrigins))
|
||||
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
|
||||
themeService := themesapp.NewService(postgres.NewThemeStore(pool))
|
||||
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(adminService), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), users.WithReservedUsernames(cfg.ReservedUsernames))
|
||||
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(userProjectionFacts), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), users.WithReservedUsernames(cfg.ReservedUsernames))
|
||||
privacyService.ConfigureReadModels(usersService, channelStore)
|
||||
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
|
||||
botsService.SetAIChatGenerator(aiComposeService)
|
||||
|
|
@ -1137,9 +1325,21 @@ func run(logger *zap.Logger) error {
|
|||
dialogs.WithContactStore(contactStore),
|
||||
dialogs.WithPhotoProvider(cachedPhotos),
|
||||
dialogs.WithPrivacyEvaluator(privacyService),
|
||||
dialogs.WithAccountFreezeProvider(adminService),
|
||||
dialogs.WithAccountFreezeProvider(userProjectionFacts),
|
||||
dialogs.WithPremiumChecker(usersService.PremiumActive),
|
||||
dialogs.WithReadModelVersions(readModelVersionStore),
|
||||
dialogs.WithDialogHydrationCaches(
|
||||
cfg.DialogPrivatePeerCacheMaxEntries,
|
||||
cfg.DialogPrivatePeerCacheMaxBytes,
|
||||
cfg.DialogDraftCacheMaxEntries,
|
||||
cfg.DialogDraftCacheMaxBytes,
|
||||
),
|
||||
dialogs.WithDialogListSnapshotCache(
|
||||
cfg.DialogListSnapshotCacheMaxEntries,
|
||||
cfg.DialogListSnapshotCacheMaxHeaders,
|
||||
cfg.DialogListSnapshotCacheTTL,
|
||||
),
|
||||
dialogs.WithSharedDialogListSnapshotCache(dialogListSnapshotCache),
|
||||
)
|
||||
// 编译期保证 *users.Service 满足 channel fan-out 跨 viewer 投影预热的可选能力;签名漂移会在
|
||||
// 这里立刻断编译,而非在运行时静默退化回 O(viewer) 逐 viewer 投影。
|
||||
|
|
@ -1147,11 +1347,19 @@ func run(logger *zap.Logger) error {
|
|||
channelsService := channelapp.NewService(channelStore,
|
||||
channelapp.WithBotProfileResolver(botsService),
|
||||
channelapp.WithReadModelVersions(readModelVersionStore),
|
||||
channelapp.WithActiveChannelIDsReadModel(
|
||||
activeChannelIDsPageCache,
|
||||
activeChannelIDsPageBatcher,
|
||||
cfg.ActiveChannelIDsCacheMaxEntries,
|
||||
cfg.ActiveChannelIDsCacheTTL,
|
||||
metricRegistry,
|
||||
),
|
||||
channelapp.WithSendPermissionChecker(adminService),
|
||||
channelapp.WithReservedUsernames(cfg.ReservedUsernames),
|
||||
)
|
||||
communitiesService := communitiesapp.NewService(communityStore)
|
||||
ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService)
|
||||
welcomeMessageService := welcomemessagesapp.NewService(welcomeMessageStore, channelsService)
|
||||
storiesService := storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService))
|
||||
chatlistsService := chatlistsapp.NewService(
|
||||
chatlistStore,
|
||||
|
|
@ -1164,7 +1372,7 @@ func run(logger *zap.Logger) error {
|
|||
messageapp.WithContactStore(contactStore),
|
||||
messageapp.WithPhotoProvider(cachedPhotos),
|
||||
messageapp.WithPrivacyEvaluator(privacyService),
|
||||
messageapp.WithAccountFreezeProvider(adminService),
|
||||
messageapp.WithAccountFreezeProvider(userProjectionFacts),
|
||||
messageapp.WithReadModelVersions(readModelVersionStore),
|
||||
messageapp.WithBotResponder(botsService),
|
||||
messageapp.WithSendPermissionChecker(adminService),
|
||||
|
|
@ -1191,7 +1399,7 @@ func run(logger *zap.Logger) error {
|
|||
dialogStore,
|
||||
newTranslationOptions(cfg, rateLimiter, logger)...,
|
||||
)
|
||||
authService := auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode,
|
||||
authService := auth.NewService(userStore, authzStore, codeStore, authKeyGetBatchStore, tempAuthKeyStore, cfg.DevAuthCode,
|
||||
auth.WithLoginMessages(messageStore, dialogStore),
|
||||
auth.WithLoginCodeDelivery(messageStore),
|
||||
auth.WithPasswords(passwordStore),
|
||||
|
|
@ -1285,6 +1493,14 @@ func run(logger *zap.Logger) error {
|
|||
logger.Info("default verifier seed complete", zap.Int64("bot_id", domain.VerifierBotUserID))
|
||||
}
|
||||
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
|
||||
var appUpdateResolver updatecdn.Resolver
|
||||
if cfg.UpdateServiceURL != "" {
|
||||
client, err := updatecdn.NewClient(cfg.UpdateServiceURL, cfg.UpdateRequestTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize update service client: %w", err)
|
||||
}
|
||||
appUpdateResolver = client
|
||||
}
|
||||
router := rpc.New(rpc.Config{
|
||||
DC: cfg.DC,
|
||||
DefaultCountryCode: cfg.DefaultCountryCode,
|
||||
|
|
@ -1304,17 +1520,29 @@ func run(logger *zap.Logger) error {
|
|||
GroupCallMaxParticipants: cfg.GroupCallMaxParticipants,
|
||||
RtmpIngestURL: cfg.LiveStreamRtmpURL,
|
||||
PublicBaseURL: cfg.PublicBaseURL,
|
||||
UpdatePublicURL: cfg.UpdatePublicURL,
|
||||
PublicAppScheme: cfg.PublicAppScheme,
|
||||
PublicAppLinkBase: cfg.PublicAppLinkBase,
|
||||
// PFS temp→perm 解析缓存:显式撤销会清缓存并断开连接,re-bind 即时失效;
|
||||
// 配置 TTL 只承担跨进程/异常失效兜底,避免大连接数周期性打满 PG。
|
||||
TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL,
|
||||
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
|
||||
PeerIdentityCacheMaxEntries: cfg.PeerIdentityCacheMaxEntries,
|
||||
StoryActivePeerCacheMaxEntries: cfg.StoryActivePeerCacheMaxEntries,
|
||||
StoryHiddenListCacheMaxEntries: cfg.StoryHiddenListCacheMaxEntries,
|
||||
StoryHiddenListCacheMaxBytes: cfg.StoryHiddenListCacheMaxBytes,
|
||||
PresenceLastSeenBatchMax: cfg.PresenceLastSeenBatchMax,
|
||||
PresenceLastSeenBatchWait: cfg.PresenceLastSeenBatchWait,
|
||||
PresenceLastSeenBatchQueue: cfg.PresenceLastSeenBatchQueue,
|
||||
PresenceLastSeenBatchTimeout: cfg.PresenceLastSeenBatchTimeout,
|
||||
PresenceLastSeenDrainTimeout: cfg.PresenceLastSeenDrainTimeout,
|
||||
}, rpc.Deps{
|
||||
Auth: authService,
|
||||
AuthDeliveryReports: authDeliveryReportService,
|
||||
ClientTelemetry: clientTelemetryService,
|
||||
AuthKeySessionLayers: authKeyStore,
|
||||
AuthKeySessionLayers: authKeySessionLayerStore,
|
||||
ReadModelVersions: readModelVersionStore,
|
||||
UserProjectionFacts: userProjectionFacts,
|
||||
Account: accountService,
|
||||
Privacy: privacyService,
|
||||
Help: help.NewService(helpStore, helpStore,
|
||||
|
|
@ -1323,10 +1551,13 @@ func run(logger *zap.Logger) error {
|
|||
help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
|
||||
help.WithAccountFreezeProvider(adminService),
|
||||
),
|
||||
AccountFreeze: adminService,
|
||||
AppUpdates: appUpdateResolver,
|
||||
AccountFreeze: userProjectionFacts,
|
||||
AccountFreezeNotifications: adminService,
|
||||
AICompose: aiComposeService,
|
||||
Ephemeral: ephemeralService,
|
||||
EphemeralPush: ephemeralStore,
|
||||
WelcomeMessages: welcomeMessageService,
|
||||
Moderation: moderationService,
|
||||
Users: usersService,
|
||||
Usernames: usernamesService,
|
||||
|
|
@ -1367,8 +1598,11 @@ func run(logger *zap.Logger) error {
|
|||
readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{
|
||||
ReadModelVersions: readModelVersionStore,
|
||||
ChannelRows: channelRowCache,
|
||||
ChannelTopMessages: channelTopMessageCache,
|
||||
CommunityCatalog: communityCatalogCache,
|
||||
ChannelMembers: channelMemberCache,
|
||||
ChannelDialogs: channelDialogCache,
|
||||
ChannelDifferences: channelDifferenceCache,
|
||||
ChannelBoosts: channelBoostCache,
|
||||
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
|
||||
Dialogs: dialogsService,
|
||||
|
|
@ -1380,16 +1614,14 @@ func run(logger *zap.Logger) error {
|
|||
ChannelMediaCounts: channelsService,
|
||||
PrivateMediaCounts: messagesService,
|
||||
RPCProjections: router,
|
||||
PeerIdentities: router,
|
||||
BaseUsers: userCache,
|
||||
BotProfiles: botsService,
|
||||
AccountSettings: router,
|
||||
UserProjectionFacts: userProjectionFacts,
|
||||
}, logger.Named("store").Named("read-model-listener"))
|
||||
go readModelListener.Run(ctx)
|
||||
activeSessions.SetLifecycleObserver(router)
|
||||
broadcastStore := postgres.NewBroadcastStore(pool)
|
||||
broadcastService := broadcastapp.NewService(broadcastStore,
|
||||
broadcastapp.WithMessageSender(messageStore),
|
||||
broadcastapp.WithLogger(logger.Named("broadcast")))
|
||||
adminService.Configure(adminapp.Dependencies{
|
||||
Auth: authService,
|
||||
Revoker: router,
|
||||
|
|
@ -1455,6 +1687,7 @@ func run(logger *zap.Logger) error {
|
|||
cache: rpcProjectionVerificationNotifier{
|
||||
invalidator: router,
|
||||
users: userCache,
|
||||
peerIdentity: true,
|
||||
log: verificationLogger,
|
||||
},
|
||||
edge: notifier,
|
||||
|
|
@ -1474,7 +1707,9 @@ func run(logger *zap.Logger) error {
|
|||
// not wait on however long sending to all of them takes.
|
||||
go broadcastapp.NewWorker(broadcastService, logger.Named("broadcast").Named("delivery"),
|
||||
cfg.BroadcastWorkerInterval, cfg.BroadcastWorkerBatch).Run(ctx)
|
||||
moderationActionOptions := []moderationapp.ActionExecutorOption{}
|
||||
moderationActionOptions := []moderationapp.ActionExecutorOption{
|
||||
moderationapp.WithAccountDeletionNotifier(router),
|
||||
}
|
||||
if cfg.PublicLinkWebAddr != "" {
|
||||
moderationActionOptions = append(
|
||||
moderationActionOptions,
|
||||
|
|
@ -1503,6 +1738,7 @@ func run(logger *zap.Logger) error {
|
|||
rpc.WithOutboxUpdateBuilder(router.BuildOutboxUpdates),
|
||||
).Run(ctx)
|
||||
go rpc.NewBootstrapUpdateDispatcher(router, logger.Named("rpc").Named("bootstrap")).Run(ctx)
|
||||
go rpc.NewWelcomeDeliveryDispatcher(router, welcomeMessageStore, logger.Named("rpc").Named("welcome-delivery")).Run(ctx)
|
||||
go rpc.NewScheduledDispatcher(router, logger.Named("rpc").Named("scheduled")).Run(ctx)
|
||||
go rpc.NewSuggestedPostDispatcher(router, logger.Named("rpc").Named("suggested-post")).Run(ctx)
|
||||
go rpc.NewExpiryDispatcher(router, logger.Named("rpc").Named("expiry")).Run(ctx)
|
||||
|
|
@ -1510,6 +1746,7 @@ func run(logger *zap.Logger) error {
|
|||
go rpc.NewGroupCallSweepDispatcher(router, logger.Named("rpc").Named("groupcall-sweep"), cfg.GroupCallSweepInterval, cfg.GroupCallCheckTTL).Run(ctx)
|
||||
go router.RunChannelFanout(ctx)
|
||||
go router.RunBotAPIEnqueue(ctx)
|
||||
go router.RunPresenceLastSeenBatch(ctx)
|
||||
go router.RunPresenceSweeper(ctx, time.Minute)
|
||||
go activeSessions.RunPendingSweeper(ctx, time.Minute)
|
||||
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
|
||||
|
|
@ -1567,7 +1804,7 @@ func run(logger *zap.Logger) error {
|
|||
RSAKey: rsaKey,
|
||||
IdentityDir: cfg.IdentityDir,
|
||||
LayerRPC: router,
|
||||
AuthKeys: authKeyStore,
|
||||
AuthKeys: authKeyGetBatchStore,
|
||||
ActiveSessions: activeSessions,
|
||||
Metrics: metricRegistry,
|
||||
ObfuscatedTCP: true,
|
||||
|
|
@ -1582,6 +1819,8 @@ func run(logger *zap.Logger) error {
|
|||
RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers,
|
||||
RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks,
|
||||
RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes,
|
||||
RPCDeliveryHookWorkers: cfg.MTProtoRPCDeliveryHookWorkers,
|
||||
RPCDeliveryHookMaxPending: cfg.MTProtoRPCDeliveryHookMaxPending,
|
||||
RPCExecutionMaxEntries: cfg.MTProtoRPCExecutionMaxEntries,
|
||||
RPCExecutionAuthMaxEntries: cfg.MTProtoRPCExecutionAuthMaxEntries,
|
||||
RPCExecutionSessionMaxEntries: cfg.MTProtoRPCExecutionSessionMaxEntries,
|
||||
|
|
|
|||
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,
|
||||
random_id integer NOT NULL,
|
||||
date integer NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT secret_chats_nonzero_id CHECK ((chat_id <> 0)),
|
||||
CONSTRAINT secret_chats_random_id_is_chat_id CHECK ((chat_id = random_id))
|
||||
);
|
||||
|
||||
|
||||
|
|
@ -4524,13 +4526,6 @@ CREATE INDEX upload_parts_object_key_idx ON public.upload_parts USING btree (obj
|
|||
CREATE UNIQUE INDEX uq_emq_dedup ON public.encrypted_message_queue USING btree (receiver_auth_key_id, chat_id, random_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: uq_secret_chats_admin_random; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE UNIQUE INDEX uq_secret_chats_admin_random ON public.secret_chats USING btree (admin_auth_key_id, random_id) WHERE (state <> 'discarded'::text);
|
||||
|
||||
|
||||
--
|
||||
-- Name: user_channel_member_index_admined_public_idx; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
|
|
|||
|
|
@ -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/gotd/ige v0.3.0
|
||||
github.com/gotd/log/logzap v0.1.1
|
||||
github.com/iamxvbaba/td v1.2.1
|
||||
github.com/iamxvbaba/td v1.3.2
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/klauspost/compress v1.19.1
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1
|
||||
github.com/minio/minio-go/v7 v7.2.1
|
||||
github.com/pion/datachannel v1.6.2
|
||||
|
|
|
|||
8
go.sum
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/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI=
|
||||
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
|
||||
github.com/iamxvbaba/td v1.2.1 h1:5+Ji1F/tdrN8zUxeeEbTPHBQGSnTDE+UAH+8pQi7O1Y=
|
||||
github.com/iamxvbaba/td v1.2.1/go.mod h1:INkZJi18XbXtVOrldDnPtmQCJvIXhgDZdbo9MV/NY7M=
|
||||
github.com/iamxvbaba/td v1.3.2 h1:/EwvDU0oiArAof16WDDGfUXg/w02m2MqzDskCnXakZA=
|
||||
github.com/iamxvbaba/td v1.3.2/go.mod h1:INkZJi18XbXtVOrldDnPtmQCJvIXhgDZdbo9MV/NY7M=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
|
|
@ -141,6 +141,8 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
|||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/nyaruka/phonenumbers v1.8.1 h1:2K9YMQuv1dCGqjjzB1DwmdCe89khT4KPBQb2CxAMMlU=
|
||||
github.com/nyaruka/phonenumbers v1.8.1/go.mod h1:fsKPJ70O9JetEA4ggnJadYTFWwtGPvu/lETTXNXq6Cs=
|
||||
github.com/ogen-go/ogen v1.23.0 h1:QaWeKm2KZ2zy7NkqqO1Vdl5idNqlG+svxdgwVAX+zbo=
|
||||
github.com/ogen-go/ogen v1.23.0/go.mod h1:bwwvC3AmCV+LrL5lazyQwwof90402mdcSyI0FOzzpfM=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
|
|
@ -274,6 +276,8 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
|||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
|
|
|||
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"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
|
@ -207,10 +209,15 @@ type UsersService interface {
|
|||
// AccountService carries the login-email factor (account_passwords table),
|
||||
// a separate concern from UsersService's users-table fields.
|
||||
type AccountService interface {
|
||||
// ValidLoginEmail reports whether email is an acceptable login/signup
|
||||
// email address.
|
||||
ValidLoginEmail(email string) bool
|
||||
// SetLoginEmail force-sets a user's login/signup email, no OTP required.
|
||||
SetLoginEmail(ctx context.Context, userID int64, email string) error
|
||||
// ClearLoginEmail removes the login email factor entirely.
|
||||
ClearLoginEmail(ctx context.Context, userID int64) error
|
||||
// LoginEmail returns a user's current login/signup email, if any.
|
||||
LoginEmail(ctx context.Context, userID int64) (string, bool, error)
|
||||
}
|
||||
|
||||
// BroadcastService creates and lists system broadcast campaigns (a message
|
||||
|
|
@ -290,6 +297,11 @@ type StickerSetsService interface {
|
|||
// ValidateStickerMaterialUpload is a pure check (no store writes) so a dry-run
|
||||
// preview can validate an uploaded file's shape without materializing it.
|
||||
ValidateStickerMaterialUpload(fileName string, data []byte) (mimeType string, ok bool)
|
||||
// ValidateAdminCreateStickerSet and ValidateAdminAddStickerToSet are pure
|
||||
// checks (no store writes), used by a dry-run preview before the
|
||||
// corresponding Admin* call actually mutates the pack.
|
||||
ValidateAdminCreateStickerSet(ctx context.Context, title, shortName, emoji string, kind domain.StickerSetKind) error
|
||||
ValidateAdminAddStickerToSet(ctx context.Context, setID int64, emoji string) error
|
||||
AdminUploadStickerMaterial(ctx context.Context, fileName string, data []byte) (domain.Document, error)
|
||||
AdminCreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error)
|
||||
AdminAddStickerToSet(ctx context.Context, setID int64, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error)
|
||||
|
|
@ -698,6 +710,7 @@ type CreateGifCatalogEntryRequest struct {
|
|||
Title string `json:"title"`
|
||||
FileName string `json:"file_name"`
|
||||
Data []byte `json:"-"`
|
||||
ContentSHA256 string `json:"content_sha256,omitempty"`
|
||||
}
|
||||
|
||||
type SetGifCatalogEnabledRequest struct {
|
||||
|
|
@ -938,6 +951,7 @@ type TransferCollectibleUsernameRequest struct {
|
|||
type RevokeCollectibleUsernameRequest struct {
|
||||
CommandMeta
|
||||
Username string `json:"username"`
|
||||
ExpectedOwnerUserID int64 `json:"expected_owner_user_id,string,omitempty"`
|
||||
Burn bool `json:"burn"`
|
||||
}
|
||||
|
||||
|
|
@ -1365,11 +1379,14 @@ func (s *Service) SetProfile(ctx context.Context, req SetProfileRequest) (Comman
|
|||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if domain.IsSystemUserID(req.UserID) {
|
||||
return CommandResult{}, fmt.Errorf("system user profile cannot be changed")
|
||||
}
|
||||
if s == nil || s.users == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
|
||||
}
|
||||
firstName := strings.TrimSpace(req.FirstName)
|
||||
lastName := strings.TrimSpace(req.LastName)
|
||||
req.FirstName = strings.TrimSpace(req.FirstName)
|
||||
req.LastName = strings.TrimSpace(req.LastName)
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetProfile, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||
if err != nil {
|
||||
|
|
@ -1379,18 +1396,21 @@ func (s *Service) SetProfile(ctx context.Context, req SetProfileRequest) (Comman
|
|||
return CommandResult{}, domain.ErrUserNotFound
|
||||
}
|
||||
details := map[string]any{
|
||||
"previous_first_name": u.FirstName, "previous_last_name": u.LastName,
|
||||
"new_first_name": firstName, "new_last_name": lastName,
|
||||
"previous_first_name": u.FirstName,
|
||||
"previous_last_name": u.LastName,
|
||||
"new_first_name": req.FirstName,
|
||||
"new_last_name": req.LastName,
|
||||
"would_change": u.FirstName != req.FirstName || u.LastName != req.LastName,
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
return CommandResult{Message: "profile update validated", Details: details}, nil
|
||||
}
|
||||
updated, err := s.users.UpdateProfile(ctx, req.UserID, domain.UserProfileUpdate{
|
||||
FirstName: firstName, HasFirstName: true,
|
||||
LastName: lastName, HasLastName: true,
|
||||
FirstName: req.FirstName, HasFirstName: true,
|
||||
LastName: req.LastName, HasLastName: true,
|
||||
})
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
||||
details["notify_error"] = err.Error()
|
||||
|
|
@ -1409,7 +1429,10 @@ func (s *Service) SetPhone(ctx context.Context, req SetPhoneRequest) (CommandRes
|
|||
if s == nil || s.users == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
|
||||
}
|
||||
phone := strings.TrimSpace(req.Phone)
|
||||
req.Phone = domain.NormalizePhone(req.Phone)
|
||||
if !domain.ValidPhone(req.Phone) {
|
||||
return CommandResult{}, domain.ErrPhoneNumberInvalid
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetPhone, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||
if err != nil {
|
||||
|
|
@ -1418,15 +1441,22 @@ func (s *Service) SetPhone(ctx context.Context, req SetPhoneRequest) (CommandRes
|
|||
if !found {
|
||||
return CommandResult{}, domain.ErrUserNotFound
|
||||
}
|
||||
details := map[string]any{"previous_phone": u.Phone, "new_phone": phone}
|
||||
if u.Bot || domain.IsSystemUserID(u.ID) {
|
||||
return CommandResult{}, domain.ErrPhoneChangeForbidden
|
||||
}
|
||||
details := map[string]any{
|
||||
"previous_phone": u.Phone,
|
||||
"new_phone": req.Phone,
|
||||
"would_change": u.Phone != req.Phone,
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
return CommandResult{Message: "phone update validated", Details: details}, nil
|
||||
}
|
||||
updated, err := s.users.SetPhone(ctx, req.UserID, phone)
|
||||
updated, err := s.users.SetPhone(ctx, req.UserID, req.Phone)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["updated_phone"] = updated.Phone
|
||||
details["changed"] = u.Phone != updated.Phone
|
||||
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
||||
details["notify_error"] = err.Error()
|
||||
}
|
||||
|
|
@ -1442,26 +1472,46 @@ func (s *Service) SetLoginEmail(ctx context.Context, req SetLoginEmailRequest) (
|
|||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if s == nil || s.account == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin account dependency is not configured")
|
||||
if s == nil || s.users == nil || s.account == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin account dependencies are not configured")
|
||||
}
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
if req.Email != "" && !s.account.ValidLoginEmail(req.Email) {
|
||||
return CommandResult{}, domain.ErrEmailInvalid
|
||||
}
|
||||
email := strings.TrimSpace(req.Email)
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetLoginEmail, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"new_login_email": email}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
var err error
|
||||
if email == "" {
|
||||
err = s.account.ClearLoginEmail(ctx, req.UserID)
|
||||
} else {
|
||||
err = s.account.SetLoginEmail(ctx, req.UserID, email)
|
||||
}
|
||||
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if !found {
|
||||
return CommandResult{}, domain.ErrUserNotFound
|
||||
}
|
||||
if u.Bot || domain.IsSystemUserID(u.ID) {
|
||||
return CommandResult{}, domain.ErrEmailInvalid
|
||||
}
|
||||
previous, _, err := s.account.LoginEmail(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
details := map[string]any{
|
||||
"previous_login_email": previous,
|
||||
"new_login_email": req.Email,
|
||||
"would_change": !strings.EqualFold(previous, req.Email),
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "login email update validated", Details: details}, nil
|
||||
}
|
||||
if req.Email == "" {
|
||||
err = s.account.ClearLoginEmail(ctx, req.UserID)
|
||||
} else {
|
||||
err = s.account.SetLoginEmail(ctx, req.UserID, req.Email)
|
||||
}
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
message := "login email updated"
|
||||
if email == "" {
|
||||
if req.Email == "" {
|
||||
message = "login email cleared"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
|
|
@ -1475,31 +1525,37 @@ func (s *Service) SetAccountAvatar(ctx context.Context, req SetAccountAvatarRequ
|
|||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if s == nil || s.photos == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin photos dependency is not configured")
|
||||
if domain.IsSystemUserID(req.UserID) {
|
||||
return CommandResult{}, fmt.Errorf("system user avatar cannot be changed")
|
||||
}
|
||||
if s == nil || s.users == nil || s.photos == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin avatar dependencies are not configured")
|
||||
}
|
||||
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
|
||||
return CommandResult{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountAvatar, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"file_name": req.FileName, "bytes": len(req.Data)}
|
||||
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if !found {
|
||||
return CommandResult{}, domain.ErrUserNotFound
|
||||
}
|
||||
details := map[string]any{"file_name": req.FileName, "bytes": len(req.Data), "bot": u.Bot}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "avatar validated", Details: details}, nil
|
||||
return CommandResult{Message: "avatar update validated", Details: details}, nil
|
||||
}
|
||||
photo, err := s.photos.CreateAvatarFromBytes(ctx, req.Data, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if _, _, err := s.photos.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, req.UserID, domain.ProfilePhotoKindProfile, photo.ID, int(time.Now().Unix())); err != nil {
|
||||
if _, _, err := s.photos.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, req.UserID, domain.ProfilePhotoKindProfile, photo.ID, int(s.now().Unix())); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["photo_id"] = photo.ID
|
||||
if s.users != nil {
|
||||
if u, found, uerr := s.users.AdminUser(ctx, req.UserID); uerr == nil && found {
|
||||
if nerr := s.notifyUserChanged(ctx, u); nerr != nil {
|
||||
details["notify_error"] = nerr.Error()
|
||||
}
|
||||
}
|
||||
details["photo_id"] = strconv.FormatInt(photo.ID, 10)
|
||||
if err := s.notifyUserChanged(ctx, u); err != nil {
|
||||
details["notify_error"] = err.Error()
|
||||
}
|
||||
return CommandResult{Message: "avatar updated", Details: details}, nil
|
||||
})
|
||||
|
|
@ -1514,20 +1570,28 @@ func (s *Service) SetChannelAvatar(ctx context.Context, req SetChannelAvatarRequ
|
|||
if req.ChannelID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("channel_id is required")
|
||||
}
|
||||
if s == nil || s.photos == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin photos dependency is not configured")
|
||||
}
|
||||
if s.channels == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin channel dependency is not configured")
|
||||
if s == nil || s.channels == nil || s.photos == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin channel avatar dependencies are not configured")
|
||||
}
|
||||
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
|
||||
return CommandResult{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelAvatar, 0, target, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"file_name": req.FileName, "bytes": len(req.Data)}
|
||||
channel, err := s.channels.GetChannelByID(ctx, req.ChannelID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if channel.Deleted || channel.Monoforum || (!channel.Broadcast && !channel.Megagroup) {
|
||||
return CommandResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
details := map[string]any{
|
||||
"file_name": req.FileName,
|
||||
"bytes": len(req.Data),
|
||||
"previous_photo_id": strconv.FormatInt(channel.PhotoID, 10),
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "avatar validated", Details: details}, nil
|
||||
return CommandResult{Message: "channel avatar update validated", Details: details}, nil
|
||||
}
|
||||
photo, err := s.photos.CreateAvatarFromBytes(ctx, req.Data, 0)
|
||||
if err != nil {
|
||||
|
|
@ -1537,7 +1601,7 @@ func (s *Service) SetChannelAvatar(ctx context.Context, req SetChannelAvatarRequ
|
|||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["photo_id"] = photo.ID
|
||||
details["photo_id"] = strconv.FormatInt(photo.ID, 10)
|
||||
if err := s.notifyChannelChanged(ctx, updated); err != nil {
|
||||
details["notify_error"] = err.Error()
|
||||
}
|
||||
|
|
@ -1562,38 +1626,10 @@ func (s *Service) ChannelAvatar(ctx context.Context, channelID int64) ([]byte, s
|
|||
return nil, "", false, nil
|
||||
}
|
||||
photo, found, err := s.photos.GetPhoto(ctx, channel.PhotoID)
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
if err != nil || !found {
|
||||
return nil, "", found, err
|
||||
}
|
||||
if !found {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
size, inline, ok := bestAccountPhotoSize(photo.Sizes)
|
||||
if !ok {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
data := inline
|
||||
if len(data) == 0 {
|
||||
chunk, found, err := s.photos.GetFile(ctx, domain.FileDownloadRequest{
|
||||
LocationKey: fmt.Sprintf("photo:%d:%s", photo.ID, size.Type),
|
||||
Limit: MaxAccountAvatarBytes + 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
if !found || chunk.Total <= 0 || chunk.Total > MaxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
data = chunk.Bytes
|
||||
}
|
||||
if len(data) == 0 || len(data) > MaxAccountAvatarBytes {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
detected := http.DetectContentType(data)
|
||||
if !safeAccountImageType(detected) {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
return data, detected, true, nil
|
||||
return s.avatarBytes(ctx, photo)
|
||||
}
|
||||
|
||||
// SetUserColor force-sets or clears a user's name/profile color.
|
||||
|
|
@ -1970,6 +2006,9 @@ func (s *Service) RevokeCollectibleUsername(ctx context.Context, req RevokeColle
|
|||
if !domain.ValidCollectibleUsername(req.Username) {
|
||||
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
|
||||
}
|
||||
if req.ExpectedOwnerUserID < 0 {
|
||||
return CommandResult{}, fmt.Errorf("expected_owner_user_id must not be negative")
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeCollectibleUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"username": req.Username, "burn": req.Burn}
|
||||
asset, err := s.usernames.Collectible(ctx, req.Username)
|
||||
|
|
@ -1986,6 +2025,9 @@ func (s *Service) RevokeCollectibleUsername(ctx context.Context, req RevokeColle
|
|||
if !req.Burn && !asset.Owned() {
|
||||
return CommandResult{Details: details}, codedError(CodeCollectibleNotOwned, domain.ErrCollectibleUsernameNotOwned)
|
||||
}
|
||||
if req.ExpectedOwnerUserID > 0 && asset.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.ExpectedOwnerUserID}) {
|
||||
return CommandResult{Details: details}, codedError(CodeCollectibleNotOwned, domain.ErrCollectibleUsernameNotOwned)
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "collectible username revoke validated", Details: details}, nil
|
||||
}
|
||||
|
|
@ -2362,7 +2404,17 @@ func (s *Service) RevokeSessions(ctx context.Context, req RevokeSessionsRequest)
|
|||
if s == nil || s.auth == nil || s.revoker == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin auth dependencies are not configured")
|
||||
}
|
||||
if (req.Hash == 0 && req.KeepHash == 0 && !req.RevokeAll) || (req.Hash != 0 && (req.KeepHash != 0 || req.RevokeAll)) {
|
||||
modeCount := 0
|
||||
if req.Hash != 0 {
|
||||
modeCount++
|
||||
}
|
||||
if req.KeepHash != 0 {
|
||||
modeCount++
|
||||
}
|
||||
if req.RevokeAll {
|
||||
modeCount++
|
||||
}
|
||||
if modeCount != 1 {
|
||||
return CommandResult{}, fmt.Errorf("choose one revoke mode")
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeSessions, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
|
|
@ -2377,7 +2429,7 @@ func (s *Service) RevokeSessions(ctx context.Context, req RevokeSessionsRequest)
|
|||
details := map[string]any{
|
||||
"target_hashes": authorizationHashes(targets),
|
||||
"target_count": len(targets),
|
||||
"keep_hash": keep.Hash,
|
||||
"keep_hash": authorizationHashString(keep.Hash),
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
|
|
@ -2388,9 +2440,10 @@ func (s *Service) RevokeSessions(ctx context.Context, req RevokeSessionsRequest)
|
|||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if found {
|
||||
revoked = append(revoked, deleted)
|
||||
if !found {
|
||||
return CommandResult{}, fmt.Errorf("authorization hash not found")
|
||||
}
|
||||
revoked = append(revoked, deleted)
|
||||
} else {
|
||||
deleted, err := s.auth.ResetAuthorizations(ctx, req.UserID, keep.AuthKeyID)
|
||||
if err != nil {
|
||||
|
|
@ -2544,12 +2597,13 @@ func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, stri
|
|||
return nil, "", false, nil
|
||||
}
|
||||
photo, found, err := s.photos.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, userID, domain.ProfilePhotoKindProfile)
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
if !found {
|
||||
return nil, "", false, nil
|
||||
if err != nil || !found {
|
||||
return nil, "", found, err
|
||||
}
|
||||
return s.avatarBytes(ctx, photo)
|
||||
}
|
||||
|
||||
func (s *Service) avatarBytes(ctx context.Context, photo domain.Photo) ([]byte, string, bool, error) {
|
||||
size, inline, ok := bestAccountPhotoSize(photo.Sizes)
|
||||
if !ok {
|
||||
return nil, "", false, nil
|
||||
|
|
@ -2560,10 +2614,10 @@ func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, stri
|
|||
LocationKey: fmt.Sprintf("photo:%d:%s", photo.ID, size.Type),
|
||||
Limit: MaxAccountAvatarBytes + 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
if err != nil || !found {
|
||||
return nil, "", found, err
|
||||
}
|
||||
if !found || chunk.Total <= 0 || chunk.Total > MaxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
|
||||
if chunk.Total <= 0 || chunk.Total > MaxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
data = chunk.Bytes
|
||||
|
|
@ -2571,11 +2625,11 @@ func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, stri
|
|||
if len(data) == 0 || len(data) > MaxAccountAvatarBytes {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
detected := http.DetectContentType(data)
|
||||
if !safeAccountImageType(detected) {
|
||||
mimeType := http.DetectContentType(data)
|
||||
if !safeAccountImageType(mimeType) {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
return data, detected, true, nil
|
||||
return data, mimeType, true, nil
|
||||
}
|
||||
|
||||
func bestAccountPhotoSize(sizes []domain.PhotoSize) (domain.PhotoSize, []byte, bool) {
|
||||
|
|
@ -2659,7 +2713,7 @@ func (s *Service) SetStickerSetArchived(ctx context.Context, req SetStickerSetAr
|
|||
|
||||
func (s *Service) SetStickerSetSortOrder(ctx context.Context, req SetStickerSetSortOrderRequest) (CommandResult, error) {
|
||||
if s == nil || s.stickerSets == nil || req.SetID <= 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 {
|
||||
return CommandResult{}, fmt.Errorf("valid sticker set and service are required")
|
||||
return CommandResult{}, domain.ErrStickerSetInvalid
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetStickerSetSortOrder, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "sort_order": req.SortOrder}
|
||||
|
|
@ -2674,7 +2728,7 @@ func (s *Service) SetStickerSetSortOrder(ctx context.Context, req SetStickerSetS
|
|||
|
||||
func (s *Service) RenameStickerSet(ctx context.Context, req RenameStickerSetRequest) (CommandResult, error) {
|
||||
if s == nil || s.stickerSets == nil || req.SetID <= 0 || strings.TrimSpace(req.Title) == "" {
|
||||
return CommandResult{}, fmt.Errorf("valid sticker set, title and service are required")
|
||||
return CommandResult{}, domain.ErrStickerSetInvalid
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRenameStickerSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "title": req.Title}
|
||||
|
|
@ -2692,7 +2746,7 @@ func (s *Service) RenameStickerSet(ctx context.Context, req RenameStickerSetRequ
|
|||
|
||||
func (s *Service) DeleteStickerSet(ctx context.Context, req DeleteStickerSetRequest) (CommandResult, error) {
|
||||
if s == nil || s.stickerSets == nil || req.SetID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("valid sticker set and service are required")
|
||||
return CommandResult{}, domain.ErrStickerSetInvalid
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionDeleteStickerSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10)}
|
||||
|
|
@ -2710,7 +2764,7 @@ func (s *Service) DeleteStickerSet(ctx context.Context, req DeleteStickerSetRequ
|
|||
|
||||
func (s *Service) CreateStickerSet(ctx context.Context, req CreateStickerSetRequest) (CommandResult, error) {
|
||||
if s == nil || s.stickerSets == nil {
|
||||
return CommandResult{}, fmt.Errorf("sticker sets service is not configured")
|
||||
return CommandResult{}, domain.ErrStickerSetInvalid
|
||||
}
|
||||
if strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.ShortName) == "" || strings.TrimSpace(req.Emoji) == "" {
|
||||
return CommandResult{}, domain.ErrStickerSetFileInvalid
|
||||
|
|
@ -2728,6 +2782,9 @@ func (s *Service) CreateStickerSet(ctx context.Context, req CreateStickerSetRequ
|
|||
"title": req.Title, "short_name": req.ShortName, "kind": string(kind),
|
||||
"file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
|
||||
}
|
||||
if err := s.stickerSets.ValidateAdminCreateStickerSet(ctx, req.Title, req.ShortName, req.Emoji, kind); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "sticker pack validated", Details: details}, nil
|
||||
}
|
||||
|
|
@ -2757,7 +2814,7 @@ func (s *Service) CreateStickerSet(ctx context.Context, req CreateStickerSetRequ
|
|||
|
||||
func (s *Service) AddStickerToSet(ctx context.Context, req AddStickerToSetRequest) (CommandResult, error) {
|
||||
if s == nil || s.stickerSets == nil || req.SetID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("valid sticker set and service are required")
|
||||
return CommandResult{}, domain.ErrStickerSetInvalid
|
||||
}
|
||||
if strings.TrimSpace(req.Emoji) == "" {
|
||||
return CommandResult{}, domain.ErrStickerSetEmojiInvalid
|
||||
|
|
@ -2771,6 +2828,12 @@ func (s *Service) AddStickerToSet(ctx context.Context, req AddStickerToSetReques
|
|||
"set_id": strconv.FormatInt(req.SetID, 10), "emoji": req.Emoji,
|
||||
"file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
|
||||
}
|
||||
// Validate the target and item before materializing a loose
|
||||
// document/blob. Keeping this inside runCommand preserves replay of a
|
||||
// previously completed command even if the pack has since changed.
|
||||
if err := s.stickerSets.ValidateAdminAddStickerToSet(ctx, req.SetID, req.Emoji); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "sticker upload validated", Details: details}, nil
|
||||
}
|
||||
|
|
@ -2795,7 +2858,7 @@ func (s *Service) AddStickerToSet(ctx context.Context, req AddStickerToSetReques
|
|||
|
||||
func (s *Service) RemoveStickerFromSet(ctx context.Context, req RemoveStickerFromSetRequest) (CommandResult, error) {
|
||||
if s == nil || s.stickerSets == nil || req.SetID <= 0 || req.DocumentID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("valid sticker set, document and service are required")
|
||||
return CommandResult{}, domain.ErrStickerSetInvalid
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRemoveStickerFromSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "document_id": strconv.FormatInt(req.DocumentID, 10)}
|
||||
|
|
@ -2811,9 +2874,18 @@ func (s *Service) RemoveStickerFromSet(ctx context.Context, req RemoveStickerFro
|
|||
})
|
||||
}
|
||||
|
||||
// GifCatalog returns every GIF catalog entry for the admin console's
|
||||
// management view.
|
||||
func (s *Service) GifCatalog(ctx context.Context) ([]domain.GifCatalogEntry, error) {
|
||||
if s == nil || s.gifCatalog == nil {
|
||||
return nil, domain.ErrGifCatalogUnavailable
|
||||
}
|
||||
return s.gifCatalog.AdminListGifCatalog(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) CreateGifCatalogEntry(ctx context.Context, req CreateGifCatalogEntryRequest) (CommandResult, error) {
|
||||
if s == nil || s.gifCatalog == nil {
|
||||
return CommandResult{}, fmt.Errorf("gif catalog service is not configured")
|
||||
return CommandResult{}, domain.ErrGifCatalogUnavailable
|
||||
}
|
||||
if strings.TrimSpace(req.Title) == "" {
|
||||
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
|
||||
|
|
@ -2822,10 +2894,19 @@ func (s *Service) CreateGifCatalogEntry(ctx context.Context, req CreateGifCatalo
|
|||
if !ok {
|
||||
return CommandResult{}, domain.ErrGifCatalogFileInvalid
|
||||
}
|
||||
digest := sha256.Sum256(req.Data)
|
||||
req.ContentSHA256 = hex.EncodeToString(digest[:])
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionCreateGifCatalogEntry, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"title": req.Title, "file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
|
||||
}
|
||||
entries, err := s.gifCatalog.AdminListGifCatalog(ctx)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if len(entries) >= domain.MaxGifCatalogEntries {
|
||||
return CommandResult{Details: details}, domain.ErrGifCatalogFull
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "gif catalog entry validated", Details: details}, nil
|
||||
}
|
||||
|
|
@ -2845,7 +2926,7 @@ func (s *Service) CreateGifCatalogEntry(ctx context.Context, req CreateGifCatalo
|
|||
|
||||
func (s *Service) SetGifCatalogEnabled(ctx context.Context, req SetGifCatalogEnabledRequest) (CommandResult, error) {
|
||||
if s == nil || s.gifCatalog == nil || req.ID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")
|
||||
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetGifCatalogEnabled, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"id": strconv.FormatInt(req.ID, 10), "enabled": req.Enabled}
|
||||
|
|
@ -2860,7 +2941,7 @@ func (s *Service) SetGifCatalogEnabled(ctx context.Context, req SetGifCatalogEna
|
|||
|
||||
func (s *Service) SetGifCatalogSortOrder(ctx context.Context, req SetGifCatalogSortOrderRequest) (CommandResult, error) {
|
||||
if s == nil || s.gifCatalog == nil || req.ID <= 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 {
|
||||
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")
|
||||
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetGifCatalogSortOrder, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"id": strconv.FormatInt(req.ID, 10), "sort_order": req.SortOrder}
|
||||
|
|
@ -2938,7 +3019,7 @@ func (s *Service) DeleteUncategorizedGifs(ctx context.Context, req DeleteUncateg
|
|||
|
||||
func (s *Service) DeleteGifCatalogEntry(ctx context.Context, req DeleteGifCatalogEntryRequest) (CommandResult, error) {
|
||||
if s == nil || s.gifCatalog == nil || req.ID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")
|
||||
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionDeleteGifCatalogEntry, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"id": strconv.FormatInt(req.ID, 10)}
|
||||
|
|
@ -3201,7 +3282,7 @@ func revokeTargets(items []domain.Authorization, req RevokeSessionsRequest) ([]d
|
|||
return []domain.Authorization{a}, domain.Authorization{}, nil
|
||||
}
|
||||
}
|
||||
return nil, domain.Authorization{}, nil
|
||||
return nil, domain.Authorization{}, fmt.Errorf("authorization hash not found")
|
||||
}
|
||||
var keep domain.Authorization
|
||||
if req.KeepHash != 0 {
|
||||
|
|
@ -3227,15 +3308,26 @@ func revokeTargets(items []domain.Authorization, req RevokeSessionsRequest) ([]d
|
|||
return targets, keep, nil
|
||||
}
|
||||
|
||||
func authorizationHashes(items []domain.Authorization) []int64 {
|
||||
out := make([]int64, 0, len(items))
|
||||
func authorizationHashes(items []domain.Authorization) []string {
|
||||
hashes := make([]int64, 0, len(items))
|
||||
for _, a := range items {
|
||||
out = append(out, a.Hash)
|
||||
hashes = append(hashes, a.Hash)
|
||||
}
|
||||
sort.Slice(hashes, func(i, j int) bool { return hashes[i] < hashes[j] })
|
||||
out := make([]string, 0, len(hashes))
|
||||
for _, hash := range hashes {
|
||||
out = append(out, authorizationHashString(hash))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out
|
||||
}
|
||||
|
||||
func authorizationHashString(hash int64) string {
|
||||
if hash == 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(hash, 10)
|
||||
}
|
||||
|
||||
func normalizeIDs(ids []int) ([]int, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ const (
|
|||
// decide how much a third-party mark is worth. Handing out the queue is
|
||||
// routine; handing out the ability to appoint verifiers is not.
|
||||
PermissionBotVerificationManage = "botverification.manage"
|
||||
// PermissionPremiumManage guards grants, revocations and refunds. It is kept
|
||||
// separate from Stars grants because a Premium refund mutates both ledgers.
|
||||
PermissionPremiumManage = "premium.manage"
|
||||
// PermissionBotTokenRead is intentionally narrower than unrestricted admin
|
||||
// access because it reveals a live credential.
|
||||
PermissionBotTokenRead = "bots.token.read"
|
||||
)
|
||||
|
||||
// CodeForbidden is the stable code for a permission failure, so the panel can
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/bots/create", s.authenticated(s.handleCreateBot))
|
||||
mux.HandleFunc("POST /v1/broadcasts/create", s.authenticated(s.handleCreateBroadcast))
|
||||
mux.HandleFunc("POST /v1/bots/delete", s.authenticated(s.handleDeleteBot))
|
||||
mux.HandleFunc("POST /v1/bots/export-token", s.authenticated(s.handleExportBotToken))
|
||||
mux.HandleFunc("POST /v1/bots/export-token", s.authorized(PermissionBotTokenRead, s.handleExportBotToken))
|
||||
mux.HandleFunc("POST /v1/messages/delete", s.authenticated(s.handleDeleteMessages))
|
||||
mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory))
|
||||
mux.HandleFunc("POST /v1/stickers/set-archived", s.authenticated(s.handleSetStickerSetArchived))
|
||||
|
|
@ -390,35 +390,13 @@ func (s *Server) handleSetLoginEmail(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (s *Server) handleSetAccountAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var req admin.SetAccountAvatarRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
if !s.decodeAvatarUpload(w, r, &req.FileName, &req.Data) {
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "avatar file is required")
|
||||
if !decodeMultipartMetadata(w, r, &req) {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
|
||||
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarBytes {
|
||||
writeError(w, http.StatusBadRequest, "avatar file is empty or too large")
|
||||
return
|
||||
}
|
||||
req.FileName = header.Filename
|
||||
req.Data = data
|
||||
result, err := s.svc.SetAccountAvatar(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
|
@ -446,37 +424,54 @@ func (s *Server) handleChannelAvatar(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (s *Server) handleSetChannelAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
var req admin.SetChannelAvatarRequest
|
||||
if !s.decodeAvatarUpload(w, r, &req.FileName, &req.Data) {
|
||||
return
|
||||
}
|
||||
if !decodeMultipartMetadata(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetChannelAvatar(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
// decodeAvatarUpload parses a multipart avatar-upload form shared by the
|
||||
// account and channel avatar endpoints, reading the uploaded file into data.
|
||||
func (s *Server) decodeAvatarUpload(w http.ResponseWriter, r *http.Request, fileName *string, data *[]byte) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||
return
|
||||
return false
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var req admin.SetChannelAvatarRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "avatar file is required")
|
||||
return
|
||||
return false
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
|
||||
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarBytes {
|
||||
raw, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
|
||||
if err != nil || len(raw) == 0 || int64(len(raw)) > admin.MaxAccountAvatarBytes {
|
||||
writeError(w, http.StatusBadRequest, "avatar file is empty or too large")
|
||||
return
|
||||
return false
|
||||
}
|
||||
req.FileName = header.Filename
|
||||
req.Data = data
|
||||
result, err := s.svc.SetChannelAvatar(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
*fileName = header.Filename
|
||||
*data = raw
|
||||
return true
|
||||
}
|
||||
|
||||
// decodeMultipartMetadata decodes the JSON "metadata" form field of a
|
||||
// multipart upload into dst, rejecting unknown fields.
|
||||
func decodeMultipartMetadata(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleSetUserColor(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -634,7 +629,7 @@ func (s *Server) handleDeleteStickerSet(w http.ResponseWriter, r *http.Request)
|
|||
|
||||
func (s *Server) handleCreateStickerSet(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, domain.MaxStickerMaterialDocumentSize+(1<<20))
|
||||
r.Body = http.MaxBytesReader(w, r.Body, domain.MaxStickerMaterialDocumentSize+(2<<20))
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||
return
|
||||
|
|
@ -668,7 +663,7 @@ func (s *Server) handleCreateStickerSet(w http.ResponseWriter, r *http.Request)
|
|||
|
||||
func (s *Server) handleAddStickerToSet(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, domain.MaxStickerMaterialDocumentSize+(1<<20))
|
||||
r.Body = http.MaxBytesReader(w, r.Body, domain.MaxStickerMaterialDocumentSize+(2<<20))
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||
return
|
||||
|
|
|
|||
|
|
@ -19,6 +19,22 @@ import (
|
|||
|
||||
const accountDeletionDelay = 7 * 24 * time.Hour
|
||||
|
||||
func (s *Service) RevenueWithdrawalPasswordState(ctx context.Context, userID int64) (domain.RevenueWithdrawalPasswordState, error) {
|
||||
if s == nil || s.lifecycle == nil || userID == 0 {
|
||||
return domain.RevenueWithdrawalPasswordState{}, fmt.Errorf("revenue withdrawal password state is unavailable")
|
||||
}
|
||||
snapshot, found, err := s.lifecycle.AccountDeletionSnapshot(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.RevenueWithdrawalPasswordState{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.RevenueWithdrawalPasswordState{}, domain.ErrUserNotFound
|
||||
}
|
||||
return domain.RevenueWithdrawalPasswordState{
|
||||
HasPassword: snapshot.HasPassword, PasswordChangedAt: snapshot.PasswordUpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteAccount implements the official 2FA deletion decision. A supplied and
|
||||
// valid SRP proof always deletes immediately. Without a proof, an account whose
|
||||
// password is older than seven days and which was active during the last seven
|
||||
|
|
@ -345,17 +361,3 @@ func (s *Service) SweepDueAccountDeletions(ctx context.Context, now time.Time, l
|
|||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.lifecycle.ClaimAccountDeletionNotifications(ctx, now, limit, lease)
|
||||
}
|
||||
|
||||
func (s *Service) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return nil
|
||||
}
|
||||
return s.lifecycle.CompleteAccountDeletionNotification(ctx, id, now)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,9 +141,3 @@ func (f *fakeAccountLifecycleStore) CancelAccountDeletion(_ context.Context, use
|
|||
func (*fakeAccountLifecycleStore) DueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionCandidate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*fakeAccountLifecycleStore) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*fakeAccountLifecycleStore) CompleteAccountDeletionNotification(context.Context, int64, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,18 +15,6 @@ import (
|
|||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type reliablePhoneChangeDispatcher interface {
|
||||
UsesReliableDispatch() bool
|
||||
}
|
||||
|
||||
func (s *Service) PhoneChangeUsesReliableDispatch() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
reporter, ok := s.phoneChanges.(reliablePhoneChangeDispatcher)
|
||||
return ok && reporter.UsesReliableDispatch()
|
||||
}
|
||||
|
||||
// SendChangePhoneCode 创建只允许当前 user + perm auth_key 消费的改号验证码。
|
||||
// CodeStore 会按 purpose+user+auth_key+phone 原子轮换:同一作用域的新请求
|
||||
// 立即使旧 hash 失效,避免 Android 返回重进页面时留下并行有效验证码。
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ func TestPhoneChangeScopesCodeAndPersistsDurableEvent(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("change phone after session reconnect: %v", err)
|
||||
}
|
||||
if !result.Changed || result.User.Phone != "15550012002" || result.Event.Type != domain.UpdateEventUserPhone || result.Event.Phone != "15550012002" || result.Event.Pts != 1 {
|
||||
if !result.Changed || result.User.Phone != "15550012002" {
|
||||
t.Fatalf("change result = %+v", result)
|
||||
}
|
||||
if got := f.changes.lastRequest().ExcludeAuthKeyID; got != rawAuthKeyID {
|
||||
|
|
@ -160,7 +160,7 @@ func TestPhoneChangeScopesCodeAndPersistsDurableEvent(t *testing.T) {
|
|||
t.Fatalf("new phone resolves to %+v found=%v", got, found)
|
||||
}
|
||||
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
|
||||
if err != nil || len(events) != 1 || events[0].Type != domain.UpdateEventUserPhone || events[0].Phone != "15550012002" {
|
||||
if err != nil || len(events) != 0 {
|
||||
t.Fatalf("durable events = %+v err=%v", events, err)
|
||||
}
|
||||
if _, found, _ := f.codes.Get(f.ctx, hash); found {
|
||||
|
|
@ -194,6 +194,26 @@ func TestPhoneChangeRejectsOccupiedAndCrossAuthCode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPhoneChangeRejectsOccupiedNationalTrunkVariant(t *testing.T) {
|
||||
f := newPhoneChangeFixture(t)
|
||||
occupied, err := f.users.Create(f.ctx, domain.User{AccessHash: 103, Phone: "989981679461", FirstName: "Iran"})
|
||||
if err != nil {
|
||||
t.Fatalf("create occupied user: %v", err)
|
||||
}
|
||||
if _, _, err := f.service.SendChangePhoneCode(
|
||||
f.ctx,
|
||||
f.user.ID,
|
||||
f.authKeyID,
|
||||
77,
|
||||
"+98 0998 167 9461",
|
||||
); !errors.Is(err, domain.ErrPhoneNumberOccupied) {
|
||||
t.Fatalf("occupied trunk variant err = %v", err)
|
||||
}
|
||||
if got, found, err := f.users.ByPhone(f.ctx, "989981679461"); err != nil || !found || got.ID != occupied.ID {
|
||||
t.Fatalf("canonical owner user=%+v found=%v err=%v", got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneChangeWrongCodeExhaustsAttempts(t *testing.T) {
|
||||
f := newPhoneChangeFixture(t)
|
||||
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012005")
|
||||
|
|
@ -233,12 +253,12 @@ func TestPhoneChangeNewSendInvalidatesPreviousHash(t *testing.T) {
|
|||
t.Fatalf("new hash change: %v", err)
|
||||
}
|
||||
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
|
||||
if err != nil || len(events) != 1 || events[0].Type != domain.UpdateEventUserPhone {
|
||||
if err != nil || len(events) != 0 {
|
||||
t.Fatalf("events = %+v err=%v", events, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneChangeConcurrentReplayAppendsOneEvent(t *testing.T) {
|
||||
func TestPhoneChangeConcurrentReplayChangesOnceWithoutPTSEvent(t *testing.T) {
|
||||
f := newPhoneChangeFixture(t)
|
||||
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012007")
|
||||
if err != nil {
|
||||
|
|
@ -273,7 +293,7 @@ func TestPhoneChangeConcurrentReplayAppendsOneEvent(t *testing.T) {
|
|||
t.Fatalf("successes=%d expired=%d", successes, expired)
|
||||
}
|
||||
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
|
||||
if err != nil || len(events) != 1 || events[0].Pts != 1 {
|
||||
if err != nil || len(events) != 0 {
|
||||
t.Fatalf("events = %+v err=%v", events, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1053,6 +1053,12 @@ func (s *Service) SetLoginEmail(ctx context.Context, userID int64, email string)
|
|||
return s.passwords.Save(ctx, userID, settings)
|
||||
}
|
||||
|
||||
// ValidLoginEmail exposes the same normalization/shape gate to trusted
|
||||
// administrative dry-runs without duplicating the address policy.
|
||||
func (s *Service) ValidLoginEmail(email string) bool {
|
||||
return validLoginEmail(normalizeLoginEmail(email))
|
||||
}
|
||||
|
||||
// LoginEmail 返回已登录用户的登录邮箱原始地址(用于 verifyEmail 回显 emailVerified.email)。
|
||||
func (s *Service) LoginEmail(ctx context.Context, userID int64) (string, bool, error) {
|
||||
if s == nil || s.passwords == nil || userID == 0 {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,32 @@ func TestWebhookPhoneLoginUsesRandomSMSCode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestWebhookPhoneLoginCanonicalizesNationalTrunkBeforeOTP(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sender := &captureOTPSender{}
|
||||
svc := NewService(
|
||||
memory.NewUserStore(),
|
||||
memory.NewAuthorizationStore(),
|
||||
memory.NewCodeStore(),
|
||||
nil,
|
||||
nil,
|
||||
"fixed-code-must-not-leak",
|
||||
WithPhoneCodeDelivery(sender, 6),
|
||||
)
|
||||
|
||||
hash, err := svc.SendCode(ctx, "+98 0998 167 9461")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
if len(sender.requests) != 1 || sender.requests[0].Recipient != "989981679461" {
|
||||
t.Fatalf("OTP requests = %+v, want canonical Iran recipient", sender.requests)
|
||||
}
|
||||
_, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, "989981679461", hash, sender.requests[0].Code)
|
||||
if err != nil || !needSignUp {
|
||||
t.Fatalf("SignIn canonical variant needSignUp=%v err=%v", needSignUp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookExistingAccountRejectionKeepsDurableAppCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
|
|
|
|||
|
|
@ -64,7 +64,10 @@ func validPhone(phone string) bool {
|
|||
}
|
||||
|
||||
func systemUserLoginForbidden(u domain.User) bool {
|
||||
return domain.IsSystemUserID(u.ID)
|
||||
// Login-facing callers deliberately use the same non-enumerating error for
|
||||
// reserved identities and irreversible tombstones. The durable authorization
|
||||
// store repeats the deleted check under the user lock to close the TOCTOU gap.
|
||||
return u.Deleted || domain.IsSystemUserID(u.ID)
|
||||
}
|
||||
|
||||
func systemLoginPhoneForbidden(phone string) bool {
|
||||
|
|
@ -270,12 +273,14 @@ func NewService(users store.UserStore, auths store.AuthorizationStore, codes sto
|
|||
}
|
||||
|
||||
// BindTempAuthKey 校验并记录 TDesktop PFS temp→perm auth key 绑定。
|
||||
func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) error {
|
||||
func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (domain.TempAuthKeyBindingResult, error) {
|
||||
var validated domain.TempAuthKeyBindingResult
|
||||
if s.authKeys != nil {
|
||||
inner, protocolExpiresAt, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
|
||||
inner, protocolExpiresAt, result, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
|
||||
if err != nil {
|
||||
return err
|
||||
return domain.TempAuthKeyBindingResult{}, err
|
||||
}
|
||||
validated = result
|
||||
binding.TempSessionID = inner.TempSessionID
|
||||
// The bind request's expires_at is a signed client assertion. TDesktop
|
||||
// intentionally adds a small grace interval, while Android derives its
|
||||
|
|
@ -287,21 +292,22 @@ func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding
|
|||
// The edge may admit the frame immediately before the temporary key's
|
||||
// absolute boundary and the encrypted proof may cross it. This is a temp-key
|
||||
// rotation condition, never a destructive permanent-key proof failure.
|
||||
return ErrTempAuthKeyEmpty
|
||||
return domain.TempAuthKeyBindingResult{}, ErrTempAuthKeyEmpty
|
||||
}
|
||||
if s.tempKeys == nil {
|
||||
return nil
|
||||
return validated, nil
|
||||
}
|
||||
if err := s.tempKeys.Save(ctx, binding); err != nil {
|
||||
result, err := s.tempKeys.SaveWithState(ctx, binding)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrTempAuthKeyAlreadyBound) {
|
||||
return ErrTempAuthKeyAlreadyBound
|
||||
return domain.TempAuthKeyBindingResult{}, ErrTempAuthKeyAlreadyBound
|
||||
}
|
||||
if errors.Is(err, store.ErrAuthKeyBindingInvalid) {
|
||||
return s.classifyBindingStoreInvalid(ctx, binding)
|
||||
return domain.TempAuthKeyBindingResult{}, s.classifyBindingStoreInvalid(ctx, binding)
|
||||
}
|
||||
return err
|
||||
return domain.TempAuthKeyBindingResult{}, err
|
||||
}
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ResolveAuthKey 将已绑定的 temp auth_key 解析为对应 perm auth_key。
|
||||
|
|
@ -323,7 +329,7 @@ func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byt
|
|||
|
||||
// UserID 返回 auth_key 当前绑定的用户。未登录、或两步验证未完成时 found=false。
|
||||
func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) {
|
||||
if s == nil || s.auths == nil {
|
||||
if s == nil || s.auths == nil || s.users == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
a, found, err := s.auths.ByAuthKey(ctx, authKeyID)
|
||||
|
|
@ -334,7 +340,13 @@ func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, e
|
|||
// 两步验证未完成:业务鉴权视为未登录,仅允许 auth.checkPassword 继续。
|
||||
return 0, false, nil
|
||||
}
|
||||
if domain.IsSystemUserID(a.UserID) {
|
||||
u, userFound, err := s.users.ByID(ctx, a.UserID)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if !userFound || systemUserLoginForbidden(u) {
|
||||
// A stale row can exist only after an interrupted/legacy path. Fail closed
|
||||
// before it reaches the Router auth cache and retire it opportunistically.
|
||||
_ = s.auths.Delete(ctx, authKeyID)
|
||||
return 0, false, nil
|
||||
}
|
||||
|
|
@ -344,14 +356,18 @@ func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, e
|
|||
// PendingPasswordUserID 返回处于"待两步验证"状态的 auth_key 对应的用户。
|
||||
// UserID 对 password_pending 的 auth_key 返回未登录,auth.checkPassword 借此仍能定位待验证用户。
|
||||
func (s *Service) PendingPasswordUserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) {
|
||||
if s == nil || s.auths == nil {
|
||||
if s == nil || s.auths == nil || s.users == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
a, found, err := s.auths.ByAuthKey(ctx, authKeyID)
|
||||
if err != nil || !found || !a.PasswordPending {
|
||||
return 0, false, err
|
||||
}
|
||||
if domain.IsSystemUserID(a.UserID) {
|
||||
u, userFound, err := s.users.ByID(ctx, a.UserID)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if !userFound || systemUserLoginForbidden(u) {
|
||||
_ = s.auths.Delete(ctx, authKeyID)
|
||||
return 0, false, nil
|
||||
}
|
||||
|
|
@ -359,13 +375,24 @@ func (s *Service) PendingPasswordUserID(ctx context.Context, authKeyID [8]byte)
|
|||
}
|
||||
|
||||
// CompletePasswordSignIn 在两步验证通过后清除 password_pending,使 auth_key 转为完全授权。
|
||||
func (s *Service) CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte) error {
|
||||
func (s *Service) CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte, expectedUserID int64) error {
|
||||
if s == nil || s.auths == nil {
|
||||
return nil
|
||||
}
|
||||
if err := s.auths.MarkPasswordPassed(ctx, authKeyID); err != nil {
|
||||
if expectedUserID == 0 {
|
||||
return store.ErrAuthorizationStateChanged
|
||||
}
|
||||
if err := s.auths.MarkPasswordPassed(ctx, authKeyID, expectedUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Revalidate the active account after the CAS promotion before Router caches
|
||||
// or binds the session. Account deletion can still linearize immediately
|
||||
// after the update and must leave the caller unauthorized.
|
||||
if userID, found, err := s.UserID(ctx, authKeyID); err != nil {
|
||||
return err
|
||||
} else if !found || userID != expectedUserID {
|
||||
return ErrSystemUserLoginForbidden
|
||||
}
|
||||
// This is where a 2FA account's sign-in actually finishes — finishSignIn
|
||||
// deliberately skipped the welcome message while password_pending.
|
||||
if a, found, err := s.auths.ByAuthKey(ctx, authKeyID); err == nil && found {
|
||||
|
|
@ -1423,7 +1450,11 @@ func (s *Service) AuthKeyClientInfo(ctx context.Context, authKeyID [8]byte) (dom
|
|||
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
|
||||
return domain.AuthKeyClientInfo{}, false, nil
|
||||
}
|
||||
key, found, err := s.authKeys.Get(ctx, authKeyID)
|
||||
// Client metadata is a read-only projection. The physical connection's
|
||||
// first-frame Get and active-key heartbeat already own the durable orphan
|
||||
// lease, so this path must not turn every init/profile read into another
|
||||
// last_used_at write.
|
||||
key, found, err := s.authKeys.Revalidate(ctx, authKeyID)
|
||||
if err != nil || !found {
|
||||
return domain.AuthKeyClientInfo{}, found, err
|
||||
}
|
||||
|
|
@ -1499,6 +1530,16 @@ func (s *Service) ResetAuthorizations(ctx context.Context, userID int64, keepAut
|
|||
}
|
||||
|
||||
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {
|
||||
if s == nil || s.users == nil || s.auths == nil || userID == 0 {
|
||||
return ErrSystemUserLoginForbidden
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || systemUserLoginForbidden(u) {
|
||||
return ErrSystemUserLoginForbidden
|
||||
}
|
||||
if s.authKeys != nil {
|
||||
key, found, err := s.authKeys.Get(ctx, auth.AuthKeyID)
|
||||
if err != nil {
|
||||
|
|
@ -1519,6 +1560,9 @@ func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID in
|
|||
if errors.Is(err, store.ErrAuthKeyNotPermanent) {
|
||||
return ErrAuthKeyPermEmpty
|
||||
}
|
||||
if errors.Is(err, domain.ErrAccountDeleted) || errors.Is(err, domain.ErrUserNotFound) {
|
||||
return ErrSystemUserLoginForbidden
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
|
@ -1535,17 +1579,19 @@ func (s *Service) passwordNeeded(ctx context.Context, userID int64) (bool, error
|
|||
return found && settings.HasPassword, nil
|
||||
}
|
||||
|
||||
const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
|
||||
func loginMessageTemplate() string {
|
||||
return `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
|
||||
|
||||
This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else.
|
||||
|
||||
If you didn't request this code by trying to log in on another device, simply ignore this message.`
|
||||
}
|
||||
|
||||
func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code string) (domain.Message, error) {
|
||||
if s.messages == nil || s.dialogs == nil {
|
||||
return domain.Message{}, nil
|
||||
}
|
||||
body := fmt.Sprintf(loginMessageTpl, code)
|
||||
body := fmt.Sprintf(loginMessageTemplate(), code)
|
||||
codeOffset := len("Login code: ")
|
||||
msg, err := s.messages.Create(ctx, domain.Message{
|
||||
OwnerUserID: userID,
|
||||
|
|
@ -1595,53 +1641,58 @@ func (s *Service) recordWelcomeMessage(ctx context.Context, u domain.User) {
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, int, error) {
|
||||
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, int, domain.TempAuthKeyBindingResult, error) {
|
||||
if binding.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrExpiresAtInvalid
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrExpiresAtInvalid
|
||||
}
|
||||
temp, found, err := s.authKeys.Get(ctx, binding.TempAuthKeyID)
|
||||
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
|
||||
pair, err := s.authKeys.LoadBindingKeys(ctx, binding.TempAuthKeyID, permID)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, err
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, err
|
||||
}
|
||||
// expires_at in auth.bindTempAuthKey is client-supplied and must only attest
|
||||
// to a still-live binding. It may never create or reclassify a protocol key;
|
||||
// the caller normalizes durable retention to this handshake-authoritative
|
||||
// temp.ExpiresAt instead of trusting the client value.
|
||||
if !found || temp.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrTempAuthKeyEmpty
|
||||
if !pair.TemporaryFound || pair.Temporary.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrTempAuthKeyEmpty
|
||||
}
|
||||
|
||||
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
|
||||
perm, found, err := s.authKeys.Get(ctx, permID)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, err
|
||||
}
|
||||
if !found || perm.ExpiresAt != 0 {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
|
||||
if !pair.PermanentFound || pair.Permanent.ExpiresAt != 0 {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
|
||||
inner, err := decryptBindAuthKeyInner(perm, binding.EncryptedMessage)
|
||||
inner, err := decryptBindAuthKeyInner(pair.Permanent, binding.EncryptedMessage)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
if inner.Nonce != binding.Nonce ||
|
||||
inner.TempAuthKeyID != authKeyIDInt64(binding.TempAuthKeyID) ||
|
||||
inner.PermAuthKeyID != binding.PermAuthKeyID ||
|
||||
inner.TempSessionID != sessionID ||
|
||||
inner.ExpiresAt != binding.ExpiresAt {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
if temp.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrTempAuthKeyEmpty
|
||||
if pair.Temporary.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrTempAuthKeyEmpty
|
||||
}
|
||||
return inner, temp.ExpiresAt, nil
|
||||
layer, observationID, err := store.MergeAuthKeyLayerObservations(
|
||||
pair.Temporary.Layer, pair.Temporary.LayerObservationID,
|
||||
pair.Permanent.Layer, pair.Permanent.LayerObservationID,
|
||||
)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, err
|
||||
}
|
||||
return inner, pair.Temporary.ExpiresAt, domain.TempAuthKeyBindingResult{
|
||||
Layer: layer, LayerObservationID: observationID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) classifyBindingStoreInvalid(ctx context.Context, binding domain.TempAuthKeyBinding) error {
|
||||
if s == nil || s.authKeys == nil {
|
||||
return ErrEncryptedMessageInvalid
|
||||
}
|
||||
temp, found, err := s.authKeys.Get(ctx, binding.TempAuthKeyID)
|
||||
temp, found, err := s.authKeys.Revalidate(ctx, binding.TempAuthKeyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
|
|||
t.Fatalf("encrypt bind message: %v", err)
|
||||
}
|
||||
|
||||
err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
|
||||
_, err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
Nonce: nonce,
|
||||
|
|
@ -59,7 +59,7 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
|
|||
t.Fatalf("BindTempAuthKey valid message: %v", err)
|
||||
}
|
||||
|
||||
err = svc.BindTempAuthKey(ctx, sessionID+1, domain.TempAuthKeyBinding{
|
||||
_, err = svc.BindTempAuthKey(ctx, sessionID+1, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
Nonce: nonce,
|
||||
|
|
@ -89,7 +89,7 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("encrypt extended bind message: %v", err)
|
||||
}
|
||||
err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
|
||||
_, err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
Nonce: nonce,
|
||||
|
|
@ -120,17 +120,17 @@ func TestBindTempAuthKeyClassifiesExpiryWithoutDestroyingPermanentKey(t *testing
|
|||
PermAuthKeyID: permKey.IntID(),
|
||||
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
|
||||
}
|
||||
if err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrTempAuthKeyEmpty) {
|
||||
if _, err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrTempAuthKeyEmpty) {
|
||||
t.Fatalf("expired protocol temp key err = %v, want ErrTempAuthKeyEmpty", err)
|
||||
}
|
||||
|
||||
request.TempAuthKeyID = testAuthKey(0x33).ID
|
||||
if err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrTempAuthKeyEmpty) {
|
||||
if _, err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrTempAuthKeyEmpty) {
|
||||
t.Fatalf("missing protocol temp key err = %v, want ErrTempAuthKeyEmpty", err)
|
||||
}
|
||||
|
||||
request.ExpiresAt = int(time.Now().Add(-time.Second).Unix())
|
||||
if err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrExpiresAtInvalid) {
|
||||
if _, err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrExpiresAtInvalid) {
|
||||
t.Fatalf("expired request proof err = %v, want ErrExpiresAtInvalid", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -346,6 +346,59 @@ func TestAuthorizationBindRejectsTemporaryProtocolKey(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDeletedUserCannotCrossAuthorizationBoundaries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
deleted, err := users.Create(ctx, domain.User{
|
||||
Deleted: true,
|
||||
DeletedAt: time.Now().Unix(),
|
||||
DeletionSource: domain.AccountDeletionManual,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create deleted user: %v", err)
|
||||
}
|
||||
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345")
|
||||
|
||||
passkeyAuthKeyID := [8]byte{0x91}
|
||||
if _, err := svc.BindVerifiedLogin(ctx, domain.Authorization{AuthKeyID: passkeyAuthKeyID}, deleted.ID); !errors.Is(err, ErrSystemUserLoginForbidden) {
|
||||
t.Fatalf("BindVerifiedLogin deleted user err = %v, want ErrSystemUserLoginForbidden", err)
|
||||
}
|
||||
if _, found, err := authz.ByAuthKey(ctx, passkeyAuthKeyID); err != nil || found {
|
||||
t.Fatalf("deleted passkey authorization found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
|
||||
qrAuthKeyID := [8]byte{0x92}
|
||||
if _, err := svc.AcceptLoginToken(ctx, domain.Authorization{AuthKeyID: qrAuthKeyID}, deleted.ID); !errors.Is(err, ErrSystemUserLoginForbidden) {
|
||||
t.Fatalf("AcceptLoginToken deleted user err = %v, want ErrSystemUserLoginForbidden", err)
|
||||
}
|
||||
if _, found, err := authz.ByAuthKey(ctx, qrAuthKeyID); err != nil || found {
|
||||
t.Fatalf("deleted QR authorization found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
|
||||
staleAuthKeyID := [8]byte{0x93}
|
||||
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: staleAuthKeyID, UserID: deleted.ID}); err != nil {
|
||||
t.Fatalf("seed stale authorization: %v", err)
|
||||
}
|
||||
if userID, found, err := svc.UserID(ctx, staleAuthKeyID); err != nil || found || userID != 0 {
|
||||
t.Fatalf("UserID stale tombstone = %d found=%v err=%v, want unauthorized", userID, found, err)
|
||||
}
|
||||
if _, found, err := authz.ByAuthKey(ctx, staleAuthKeyID); err != nil || found {
|
||||
t.Fatalf("stale tombstone authorization found=%v err=%v, want retired", found, err)
|
||||
}
|
||||
|
||||
pendingAuthKeyID := [8]byte{0x94}
|
||||
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: pendingAuthKeyID, UserID: deleted.ID, PasswordPending: true}); err != nil {
|
||||
t.Fatalf("seed stale pending authorization: %v", err)
|
||||
}
|
||||
if err := svc.CompletePasswordSignIn(ctx, pendingAuthKeyID, deleted.ID); !errors.Is(err, ErrSystemUserLoginForbidden) {
|
||||
t.Fatalf("CompletePasswordSignIn deleted user err = %v, want ErrSystemUserLoginForbidden", err)
|
||||
}
|
||||
if _, found, err := authz.ByAuthKey(ctx, pendingAuthKeyID); err != nil || found {
|
||||
t.Fatalf("stale pending tombstone authorization found=%v err=%v, want retired", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
|
|
@ -377,6 +430,100 @@ func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestVirtual888PhoneRegistersAndSignsIn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
delivery := &captureLoginCodeDelivery{}
|
||||
svc := NewService(
|
||||
users,
|
||||
authz,
|
||||
memory.NewCodeStore(),
|
||||
nil,
|
||||
nil,
|
||||
"12345",
|
||||
WithLoginCodeDelivery(delivery),
|
||||
)
|
||||
const (
|
||||
formatted = "+888 12-34"
|
||||
canonical = "8881234"
|
||||
)
|
||||
|
||||
firstHash, err := svc.SendCode(ctx, formatted)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode virtual phone: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, canonical, firstHash, "12345")
|
||||
created, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: [8]byte{0x54}}, formatted, firstHash, "Virtual", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp virtual phone: %v", err)
|
||||
}
|
||||
if created.Phone != canonical {
|
||||
t.Fatalf("created phone = %q, want %q", created.Phone, canonical)
|
||||
}
|
||||
|
||||
secondHash, err := svc.SendCode(ctx, canonical)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode existing virtual phone: %v", err)
|
||||
}
|
||||
signedIn, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: [8]byte{0x55}}, formatted, secondHash, "12345")
|
||||
if err != nil {
|
||||
t.Fatalf("SignIn virtual phone: %v", err)
|
||||
}
|
||||
if needSignUp || signedIn.ID != created.ID {
|
||||
t.Fatalf("SignIn user=%d needSignUp=%v, want existing user %d", signedIn.ID, needSignUp, created.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIranNationalTrunkVariantsShareOneAccountIdentity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
delivery := &captureLoginCodeDelivery{}
|
||||
svc := NewService(
|
||||
users,
|
||||
authz,
|
||||
memory.NewCodeStore(),
|
||||
nil,
|
||||
nil,
|
||||
"12345",
|
||||
WithLoginCodeDelivery(delivery),
|
||||
)
|
||||
const (
|
||||
withNationalTrunk = "+98 0998 167 9461"
|
||||
international = "989981679461"
|
||||
canonical = "989981679461"
|
||||
)
|
||||
|
||||
firstHash, err := svc.SendCode(ctx, withNationalTrunk)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode trunk variant: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, international, firstHash, "12345")
|
||||
created, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: [8]byte{1}}, international, firstHash, "Iran", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp international variant: %v", err)
|
||||
}
|
||||
if created.Phone != canonical {
|
||||
t.Fatalf("created phone = %q, want %q", created.Phone, canonical)
|
||||
}
|
||||
|
||||
secondHash, err := svc.SendCode(ctx, international)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode existing international variant: %v", err)
|
||||
}
|
||||
signedIn, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: [8]byte{2}}, withNationalTrunk, secondHash, "12345")
|
||||
if err != nil {
|
||||
t.Fatalf("SignIn trunk variant: %v", err)
|
||||
}
|
||||
if needSignUp || signedIn.ID != created.ID {
|
||||
t.Fatalf("SignIn user=%d needSignUp=%v, want existing user %d", signedIn.ID, needSignUp, created.ID)
|
||||
}
|
||||
if got, found, err := users.ByPhone(ctx, canonical); err != nil || !found || got.ID != created.ID {
|
||||
t.Fatalf("canonical lookup user=%+v found=%v err=%v", got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func verifyCodeForSignUp(t *testing.T, svc *Service, phone, hash, code string) {
|
||||
t.Helper()
|
||||
got, msg, needSignUp, err := svc.SignIn(context.Background(), domain.Authorization{}, phone, hash, code)
|
||||
|
|
@ -809,7 +956,10 @@ func TestSignInExistingTwoFactorAccountNeedsPassword(t *testing.T) {
|
|||
t.Fatalf("PendingPasswordUserID = %d pending=%v err=%v, want %d", pendingUID, pending, err, u.ID)
|
||||
}
|
||||
// 两步验证通过后转为完全授权。
|
||||
if err := svc.CompletePasswordSignIn(ctx, key); err != nil {
|
||||
if err := svc.CompletePasswordSignIn(ctx, key, 0); !errors.Is(err, store.ErrAuthorizationStateChanged) {
|
||||
t.Fatalf("CompletePasswordSignIn without expected user err=%v, want authorization state changed", err)
|
||||
}
|
||||
if err := svc.CompletePasswordSignIn(ctx, key, u.ID); err != nil {
|
||||
t.Fatalf("CompletePasswordSignIn: %v", err)
|
||||
}
|
||||
bound, found, err = svc.UserID(ctx, key)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue