Compare commits
64 commits
669e515061
...
6649b70d5e
| Author | SHA1 | Date | |
|---|---|---|---|
| 6649b70d5e | |||
| 7fef8438c5 | |||
| ecdb14aaca | |||
| b565efe9d1 | |||
| c1c7db325f | |||
| 798ea70b4e | |||
| 7f0921c82c | |||
| 2e60301a08 | |||
| e81742ba51 | |||
| 38112062a1 | |||
| 8df27528b9 | |||
| 655fdacb0a | |||
| 9353953cf4 | |||
| b71f8d7fa7 | |||
| 2e96d9490e | |||
| 2d42158ec5 | |||
| 367f2be59c | |||
| 5b2f68c61e | |||
| 90af881867 | |||
| 37a3462a22 | |||
| 4e2cf7c5ac | |||
| 93a4ed2bca | |||
| 241fd442fb | |||
| 7dbb931b31 | |||
| 65aaa263b1 | |||
| 515038aace | |||
| d9ee8cbee0 | |||
| 71da34607e | |||
| 578eb5a1f6 | |||
| 3229bfa6b3 | |||
| 36d0f19767 | |||
| 25008fddc5 | |||
| 284d8be365 | |||
| 485323dbfa | |||
| f940d3403b | |||
| 0e2fc6705b | |||
| a3fc5ba3b5 | |||
| f478c87a56 | |||
| c939f7c92e | |||
| 686adc1e10 | |||
| 7083faa786 | |||
| 7d280b6f8a | |||
| 1c477fe32a | |||
| 31a6ab4887 | |||
| f94edc6cad | |||
| 5f65a47d89 | |||
| 7b662ad647 | |||
| be3623797e | |||
| 55a6e0bb35 | |||
| 2c782aab95 | |||
| 879ff48b49 | |||
| 7daaaf164f | |||
| 045f39c3f9 | |||
| bf22bd4006 | |||
| 6d34843fd0 | |||
| f26468ef6d | |||
| 233a2399e9 | |||
| f8aefb7fd5 | |||
| b3281d66d7 | |||
| 8bc54fbfc5 | |||
| 165f81414c | |||
| 11142f74c5 | |||
| 1ebf8ddb5a | |||
| 80e99e8781 |
864 changed files with 6681 additions and 82440 deletions
|
|
@ -1,42 +0,0 @@
|
|||
.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
|
||||
139
.env.example
139
.env.example
|
|
@ -40,24 +40,6 @@ TELESRV_PHONE_CODE_LENGTH=5
|
|||
TELESRV_AUTH_CODE_TTL=5m
|
||||
# How many wrong guesses are allowed before a code is rejected outright.
|
||||
TELESRV_AUTH_CODE_MAX_ATTEMPTS=5
|
||||
# The message sent from the official system account (777000) into a user's
|
||||
# own chat on every completed sign-in -- a lightweight security notice, not
|
||||
# the login code itself. {{server_name}} is replaced with the server's
|
||||
# configured identity name (or product name if unset). Leave empty to use
|
||||
# the built-in English copy. The admin panel's Server Settings page can
|
||||
# override these live, without a restart; these env vars are only the
|
||||
# fallback for when it hasn't been touched.
|
||||
TELESRV_WELCOME_MESSAGE_PHONE_TEMPLATE=
|
||||
TELESRV_WELCOME_MESSAGE_EMAIL_TEMPLATE=
|
||||
# The message sent from the official system account (777000) that carries the
|
||||
# actual login code -- one template for every delivery channel (SMS or
|
||||
# email). Must contain the {{code}} placeholder exactly once (that's where
|
||||
# the real code is inserted and bolded); {{server_name}} is optional and may
|
||||
# appear any number of times. Leave empty to use the built-in English copy.
|
||||
# The admin panel's Server Settings page can override this live, without a
|
||||
# restart (rejecting a save that doesn't contain {{code}} exactly once); this
|
||||
# env var is only the fallback for when it hasn't been touched.
|
||||
TELESRV_LOGIN_CODE_MESSAGE_TEMPLATE=
|
||||
# Where webhook-delivered codes are POSTed, and the shared secret used to
|
||||
# sign that request (see docs/otp-delivery.md for the exact payload).
|
||||
TELESRV_OTP_WEBHOOK_URL=
|
||||
|
|
@ -100,24 +82,17 @@ TELESRV_SMTP_TIMEOUT=10s
|
|||
## Public Links & Branding -- What clients show/open for links, and your product's name.
|
||||
|
||||
# Public web address for links this server generates (invite links, sticker
|
||||
# packs, etc). :2401 matches TELESRV_PUBLIC_LINK_WEB_ADDR below (the Public
|
||||
# Web Listener that actually serves those preview pages) so a link opened
|
||||
# right after setup shows a real preview card instead of a dead connection.
|
||||
# Use your real domain once you have one, e.g. https://example.com -- and
|
||||
# put nginx (or similar) in front of the listener rather than pointing this
|
||||
# at the raw port.
|
||||
TELESRV_PUBLIC_BASE_URL=http://127.0.0.1:2401
|
||||
# packs, etc). Use your real domain once you have one, e.g. https://example.com.
|
||||
TELESRV_PUBLIC_BASE_URL=http://127.0.0.1
|
||||
# Custom URL scheme (like "owpg://") that public pages use to open your
|
||||
# patched client. Must match what your client builds were compiled with.
|
||||
TELESRV_PUBLIC_APP_SCHEME=owpg
|
||||
# Optional: use "scheme://yourdomain.com/..." links instead of plain
|
||||
# "scheme://...". Leave empty unless you specifically need this.
|
||||
TELESRV_PUBLIC_APP_LINK_BASE=
|
||||
# Address of your web client, if you have one -- shows an "Open in Web"
|
||||
# button on public profile/invite pages. Leave empty (the default) to hide
|
||||
# that button; there's no web client running unless you've deployed one.
|
||||
TELESRV_PUBLIC_WEB_BASE_URL=
|
||||
# Product name shown on public landing pages.
|
||||
# Address of your web client (if you have one) and the product name shown
|
||||
# on public landing pages.
|
||||
TELESRV_PUBLIC_WEB_BASE_URL=https://web.telesrv.net
|
||||
TELESRV_PUBLIC_APP_NAME=OwpenGram
|
||||
# Where the "Download" button on public pages links to.
|
||||
TELESRV_PUBLIC_DOWNLOAD_URL=https://owpengram.org
|
||||
|
|
@ -125,11 +100,6 @@ TELESRV_PUBLIC_DOWNLOAD_URL=https://owpengram.org
|
|||
# scam/fake. Leave empty to use the built-in English text.
|
||||
TELESRV_SCAM_WARNING=
|
||||
TELESRV_FAKE_WARNING=
|
||||
# Usernames a user/channel can never self-service claim (account.updateUsername,
|
||||
# channels.updateUsername) -- brand-adjacent or staff-sounding words, plus
|
||||
# your own real handle if you want it protected too. Comma-separated, not
|
||||
# case-sensitive. The admin panel can still assign any of these on purpose.
|
||||
TELESRV_RESERVED_USERNAMES=owpengram,admin,administrator,support,staff,moderator,official,root,owner
|
||||
|
||||
## Admin Panel -- Login and access for the web-based admin dashboard.
|
||||
|
||||
|
|
@ -147,13 +117,8 @@ TELESRV_ADMIN_SESSION_KEY=
|
|||
# the admin panel entirely; set to a loopback address (127.0.0.1:...) to
|
||||
# enable it without exposing it outside this machine.
|
||||
TELESRV_ADMIN_API_ADDR=
|
||||
# Address the admin panel's own web UI listens on. 0.0.0.0 so a fresh
|
||||
# install is reachable right away from wherever you're setting it up from --
|
||||
# a VPS you're provisioning from across the world included -- without an SSH
|
||||
# tunnel just to see the first-run wizard. Narrow it to a loopback or LAN
|
||||
# address once you're done if you'd rather it not be open to the internet;
|
||||
# the login itself still needs the password (or token) below either way.
|
||||
TELESRV_ADMIN_UI_ADDR=0.0.0.0:2600
|
||||
# Address the admin panel's own web UI listens on.
|
||||
TELESRV_ADMIN_UI_ADDR=127.0.0.1:2600
|
||||
# Permissions granted to an Admin UI session that logged in with
|
||||
# TELESRV_ADMIN_UI_PASSWORD / _TOKEN. Comma-separated; "*" means every
|
||||
# permission and is the default, so enabling RBAC never locks an operator out of
|
||||
|
|
@ -304,63 +269,14 @@ TELESRV_S3_USE_SSL=false
|
|||
# MinIO needs this on (bucket in the URL path); AWS S3 does not.
|
||||
TELESRV_S3_PATH_STYLE=true
|
||||
# Reject new uploads once storage is nearly full, instead of letting the disk
|
||||
# fill up. Thresholds are the three fields right below.
|
||||
# fill up. Thresholds live in the Advanced section below.
|
||||
TELESRV_STORAGE_LOW_SPACE_GUARD_ENABLE=true
|
||||
# localfs: reject new uploads once real free disk bytes fall below this; <=0 disables.
|
||||
TELESRV_STORAGE_MIN_FREE_BYTES=1073741824
|
||||
# Reject new uploads once total tracked blob bytes would exceed this. The only
|
||||
# meaningful "low space" signal on the s3 backend (no OS free-space concept);
|
||||
# optional soft cap on localfs too. <=0 disables.
|
||||
TELESRV_STORAGE_MAX_TOTAL_BYTES=0
|
||||
# Reject a single upload once its total assembled size (sum of all its parts)
|
||||
# would exceed this. <=0 disables this check (the protocol's own part-count
|
||||
# ceiling of ~4GB still applies). Must not exceed that ceiling.
|
||||
TELESRV_STORAGE_MAX_UPLOAD_FILE_BYTES=0
|
||||
# Storage retention sweep mode: "off" (default, nothing auto-deleted --
|
||||
# storage usage is still tracked and shown in the admin panel either way),
|
||||
# "orphan" (safe: deletes a document/photo's blob only once it's no longer
|
||||
# referenced by any message/profile photo/sticker set), or "hard"
|
||||
# (aggressive: deletes a document/photo's blob once it's old enough,
|
||||
# REGARDLESS of whether it's still referenced -- old media in active
|
||||
# conversations will show as unavailable).
|
||||
TELESRV_STORAGE_RETENTION_MODE=off
|
||||
# How long a document/photo must have had zero references before the sweep
|
||||
# deletes it in "orphan" mode above -- or how old the media itself is before
|
||||
# "hard" mode deletes its bytes regardless of references. Ignored when the
|
||||
# mode above is "off". The sweep itself runs alongside every other retention
|
||||
# check on the shared TELESRV_RETENTION_INTERVAL/TELESRV_RETENTION_BATCH
|
||||
# cadence (Advanced section below).
|
||||
TELESRV_STORAGE_RETENTION_MAX_AGE=720h
|
||||
# Optional per-category overrides of the shared age above (Photo/Video/Round
|
||||
# Video/Gif/Music/Voice/File/Avatar) -- each empty/unset value falls back to
|
||||
# TELESRV_STORAGE_RETENTION_MAX_AGE. The mode switch above still applies to
|
||||
# all of them; these only let one category expire sooner or later than the
|
||||
# rest (e.g. purge voice notes after a week but keep files for a year).
|
||||
TELESRV_STORAGE_RETENTION_MAX_AGE_PHOTO=
|
||||
TELESRV_STORAGE_RETENTION_MAX_AGE_VIDEO=
|
||||
TELESRV_STORAGE_RETENTION_MAX_AGE_ROUND_VIDEO=
|
||||
TELESRV_STORAGE_RETENTION_MAX_AGE_GIF=
|
||||
TELESRV_STORAGE_RETENTION_MAX_AGE_MUSIC=
|
||||
TELESRV_STORAGE_RETENTION_MAX_AGE_VOICE=
|
||||
TELESRV_STORAGE_RETENTION_MAX_AGE_FILE=
|
||||
TELESRV_STORAGE_RETENTION_MAX_AGE_AVATAR=
|
||||
# Once enabled, actively reclaims space once total physical storage exceeds
|
||||
# TELESRV_STORAGE_MAX_TOTAL_BYTES above: the oldest files (regardless of
|
||||
# category/age) are purged the same way "hard" retention mode purges blob
|
||||
# bytes, until back under budget. Independent of the retention mode switch
|
||||
# above -- can run even when that's "off". Default false: TELESRV_STORAGE_MAX_
|
||||
# TOTAL_BYTES otherwise only ever blocks new uploads, never reclaims from
|
||||
# existing ones.
|
||||
TELESRV_STORAGE_EVICTION_ENABLE=false
|
||||
# Secret-chat encrypted files (photos/documents sent in a secret chat) are
|
||||
# opaque ciphertext the server can't inspect -- none of the retention/
|
||||
# eviction settings above ever touch them, so they otherwise accumulate
|
||||
# forever. Secret chats are single-device on both ends (no multi-device
|
||||
# sync), so once the one recipient device that will ever ask for a file has
|
||||
# downloaded it in full, the server has no further reason to keep it.
|
||||
# Default true: set to false if you'd rather keep the ciphertext around
|
||||
# regardless (it still can't be read without the secret chat's own key).
|
||||
TELESRV_SECRET_CHAT_DELETE_FILE_AFTER_DOWNLOAD=true
|
||||
# Automatically delete old media once it's no longer referenced by any
|
||||
# message, profile photo, or sticker set (never deletes media still visible
|
||||
# in a conversation). Off by default -- storage usage is tracked and shown
|
||||
# in the admin panel either way; enable this once you're comfortable with
|
||||
# what it will reclaim. Retention age lives in the Advanced section below.
|
||||
TELESRV_STORAGE_RETENTION_ENABLE=false
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
|
@ -396,7 +312,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=32768
|
||||
TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=8192
|
||||
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.
|
||||
|
|
@ -494,14 +410,23 @@ TELESRV_DEFAULT_STICKER_SET_ID=0
|
|||
# imported once (matched by filename); renaming a file re-imports it as a new
|
||||
# entry. Missing directory is skipped, not an error.
|
||||
TELESRV_GIF_SEED_DIR=data/gifs
|
||||
# Admin-editable server name/description/icon (Server Settings in the admin
|
||||
# panel), served over /owpengram/server-info + /owpengram/server-icon and
|
||||
# read fresh on every request -- editing them takes effect with no restart.
|
||||
TELESRV_IDENTITY_DIR=data/identity
|
||||
|
||||
# How often the cached free-space/usage gauge behind the low-space guard
|
||||
# (thresholds now live in the Storage & Media section above) refreshes.
|
||||
# Storage low-space guard thresholds (master toggle is TELESRV_STORAGE_LOW_SPACE_GUARD_ENABLE above).
|
||||
# localfs: reject new uploads once real free disk bytes fall below this; <=0 disables.
|
||||
TELESRV_STORAGE_MIN_FREE_BYTES=1073741824
|
||||
# Reject new uploads once total tracked blob bytes would exceed this. The only
|
||||
# meaningful "low space" signal on the s3 backend (no OS free-space concept);
|
||||
# optional soft cap on localfs too. <=0 disables.
|
||||
TELESRV_STORAGE_MAX_TOTAL_BYTES=0
|
||||
# How often the cached free-space/usage gauge behind the guard above refreshes.
|
||||
TELESRV_STORAGE_USAGE_REFRESH_INTERVAL=1m
|
||||
# Storage retention sweep tuning (master toggle is TELESRV_STORAGE_RETENTION_ENABLE above).
|
||||
# How long a document/photo must have had zero references before the sweep
|
||||
# deletes it -- not how old the media itself is, and it never touches media
|
||||
# still referenced by a live message/profile-photo/sticker-set. The sweep
|
||||
# itself runs alongside every other retention check on the shared
|
||||
# TELESRV_RETENTION_INTERVAL/TELESRV_RETENTION_BATCH cadence above.
|
||||
TELESRV_STORAGE_RETENTION_MAX_AGE=720h
|
||||
|
||||
# New-account perks: free Telegram Premium months.
|
||||
TELESRV_PREMIUM_GRANT_MONTHS=3
|
||||
|
|
@ -540,10 +465,6 @@ 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
|
||||
|
|
|
|||
15
.github/FUNDING.yml
vendored
15
.github/FUNDING.yml
vendored
|
|
@ -1,15 +0,0 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: owpengram
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
130
.github/workflows/build.yml
vendored
130
.github/workflows/build.yml
vendored
|
|
@ -1,130 +0,0 @@
|
|||
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
180
.github/workflows/ci.yml
vendored
|
|
@ -1,180 +0,0 @@
|
|||
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
76
.github/workflows/container-images.yml
vendored
|
|
@ -1,76 +0,0 @@
|
|||
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 }}
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -10,9 +10,6 @@ node_modules/
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
# install-prereqs.sh / owpengram-server.sh create this for the panel's packages.
|
||||
# Untracked and unignored, it was one `git clean -fd` away from being deleted.
|
||||
/.venv/
|
||||
|
||||
# 本地环境 / 密钥(server RSA private key 必须持久化,但禁止入库)
|
||||
.env
|
||||
|
|
@ -38,10 +35,6 @@ tmp/
|
|||
.db_naming
|
||||
.server_panel.json
|
||||
|
||||
# Local-only destructive dev tool (deletes .env, data/, Docker volumes) --
|
||||
# never belongs in the repo.
|
||||
/wipe-server.bat
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
|
|
|||
91
Dockerfile
91
Dockerfile
|
|
@ -1,91 +0,0 @@
|
|||
# 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"]
|
||||
241
README.md
241
README.md
|
|
@ -8,8 +8,8 @@
|
|||
|
||||
The protocol stack is built on the published
|
||||
[`github.com/iamxvbaba/td`](https://github.com/iamxvbaba/td) module
|
||||
(`v1.3.2`), using a canonical Layer 229 schema with sparse `tlprofile`
|
||||
exact Layer 225-229 compatibility profiles.
|
||||
(`v1.1.0`), using a canonical Layer 228 schema with sparse `tlprofile`
|
||||
exact Layer 225-228 compatibility profiles.
|
||||
|
||||
If you are looking for a **Telegram server**, **MTProto server**,
|
||||
**Telegram backend**, **Telegram clone server**, or **self-hosted
|
||||
|
|
@ -21,10 +21,7 @@ in Go. Run it on your own network for a private, closed setup, or on a VPS to
|
|||
be reachable anywhere in the world. Your data, your keys, your rules — no
|
||||
cloud, no lock-in, no censorship.
|
||||
|
||||
> 🔗 Implements **MTProto API layers 225-229** — a client is admitted on the
|
||||
> exact layer it announces, so older builds keep working after the server moves
|
||||
> forward. The running server reports its version, the layers it accepts, and
|
||||
> its build in the admin panel sidebar.
|
||||
> 🔗 Implements **MTProto API layer 228**.
|
||||
|
||||
`OwpenGram Server` is independent and unofficial. It is not affiliated with, endorsed by,
|
||||
or sponsored by Telegram or the official Telegram team.
|
||||
|
|
@ -39,9 +36,6 @@ or sponsored by Telegram or the official Telegram team.
|
|||
- 🛡️ **Censorship-resistant** — no central authority can shut you down.
|
||||
- ⚙️ **Single binary** — one Go program prepares keys, runs migrations, serves
|
||||
MTProto, and dispatches updates and background workers.
|
||||
- 📦 **One command to install** — the launcher installs the prerequisites
|
||||
it needs (Go, Python, Docker, OpenSSL), brings the stack up, and hands you
|
||||
a browser setup wizard.
|
||||
- 🆓 **Free & open source** — Apache-2.0, audit and extend it freely.
|
||||
|
||||
## 🎯 What works today
|
||||
|
|
@ -56,15 +50,6 @@ or sponsored by Telegram or the official Telegram team.
|
|||
- 🔑 Self-hosted "Log in with Telegram" (OpenID Connect) and passkey sign-in
|
||||
- 🌐 Message translation and AI-assisted compose
|
||||
- 📇 Contacts, dialogs sync, chat folders, public link landing pages
|
||||
- 🔎 **Self-configuring clients** — "Add Server" needs only `host:port`; the
|
||||
server publishes its DC id, RSA key and identity over a well-known HTTP path
|
||||
- 👥 **Multi-operator admin panel** — named operator accounts with scoped
|
||||
permissions, instead of one shared password
|
||||
- 🗄️ **Storage management** — usage breakdown, retention rules, and guarded
|
||||
purge of orphaned or expired media
|
||||
- 👋 Welcome messages and login-code templates you can edit from the panel
|
||||
- 🧙 **First-run web setup wizard** — server identity, public address, Bot API
|
||||
and your operator account, then a restart, all from the browser
|
||||
- 🖥️ Admin API and web UI for operations, plus a TUI server panel to run it all
|
||||
|
||||
<details>
|
||||
|
|
@ -72,7 +57,7 @@ or sponsored by Telegram or the official Telegram team.
|
|||
|
||||
| Status | Feature | What works today |
|
||||
|---|---|---|
|
||||
| ✅ | MTProto server edge | TCP transport, RSA key exchange, auth keys, encrypted sessions, salts, ack/resend, bad messages, RPC dispatch, canonical Layer 229, sparse exact Layer 225-229 compatibility profiles, and a same-port HTTP side that serves WebSocket transport plus the `/owpengram/server-info` and `/owpengram/server-icon` self-configuration endpoints. |
|
||||
| ✅ | MTProto server edge | TCP transport, RSA key exchange, auth keys, encrypted sessions, salts, ack/resend, bad messages, RPC dispatch, canonical Layer 228, and sparse exact Layer 225-228 compatibility profiles. |
|
||||
| ✅ | Login and accounts | Development login code, configurable external code delivery (SMS webhook or SMTP), login email as a second factor, email-as-identity sign-up (no phone number needed), sign-in, sign-up, log-out, authorizations, account settings, SRP/password state, WebAuthn passkey sign-in, and a self-hosted Telegram Login (OpenID Connect) provider for third-party sites. |
|
||||
| ✅ | Users and contacts | User profiles, usernames, profile photos, contact import/search, blocked/privacy state, presence, and last-seen style status. |
|
||||
| ✅ | Dialogs and sync | Dialog list, pinned dialogs, manual unread, folders/filters, drafts, read boundaries, durable updates, online fan-out, and offline difference recovery. |
|
||||
|
|
@ -87,24 +72,21 @@ or sponsored by Telegram or the official Telegram team.
|
|||
| ✅ | Collectible usernames and verification | Fragment-style NFT/collectible usernames (mint, transfer, activate/deactivate), the official platform-checkmark flow (`@verifybot`), and a third-party bot-verification mark mechanism (`@marksbot`, icon + description before a name) — the latter is experimental and hidden by default. |
|
||||
| ✅ | Bots and mini apps | Bot service foundations, callbacks, inline helpers, webview/mini-app paths, a minimal Bot API gateway for libraries such as `python-telegram-bot`, persistent `getUpdates` delivery, and demo tools. |
|
||||
| ✅ | Calls and live streams | Private call signaling foundations, group call state, RTMP live streaming, scheduled video chats, channel `join_as`, SFU/TURN building blocks, liveness, and expiry workers. |
|
||||
| ✅ | Admin and operations | Admin API/UI backend, a first-run web setup wizard (server identity, public network fields, optional Bot API gateway, first operator account), named operator accounts with per-section permissions and a wildcard "full access" grant, per-account freeze (admin-set read-only restriction, advertised to the client via appConfig), broadcast messaging (announce from the official account to every user or a picked list), editable welcome and login-code message templates, storage management (usage breakdown, retention rules, guarded purge), shared-device detection across accounts, RBAC-scoped admin API tokens, PostgreSQL migrations, Redis volatile state, retention workers, pprof/debug hooks, load-test helpers, one-click update with a dry run before it applies, and a bundled TUI server panel as an alternative to the web UI. |
|
||||
| ✅ | Admin and operations | Admin API/UI backend, per-account freeze (admin-set read-only restriction, advertised to the client via appConfig), broadcast messaging (announce from the official account to every user or a picked list), shared-device detection across accounts, RBAC-scoped admin API tokens, PostgreSQL migrations, Redis volatile state, retention workers, pprof/debug hooks, load-test helpers, and a bundled TUI server panel (setup wizard, start/stop/restart, one-click update via `git pull` + rebuild, live logs, `.env` editor) as an alternative to manual builds. |
|
||||
| ✅ | Desktop, Android, iOS, and Web focus | Telegram Desktop is the primary target, with Android, iOS, and Web compatibility paths actively covered by the same server. |
|
||||
|
||||
Some items are compatibility-first or experimental, but they are real open
|
||||
server code, not hidden product-only features.
|
||||
</details>
|
||||
|
||||
## 🚀 Want to see it first?
|
||||
|
||||
You do not have to run a server to try OwpenGram. We keep a **public server
|
||||
live**, and it ships inside both clients as a ready-made entry — install a
|
||||
client, pick **OwpenGram** on the server-selection screen, sign in. Nothing to
|
||||
configure.
|
||||
|
||||
Come back here when you want that server to be yours.
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
Requirements:
|
||||
|
||||
- **Go 1.25+**
|
||||
- **Docker** (or Docker Desktop), for PostgreSQL and Redis
|
||||
- OpenSSL, to export the server's RSA public key for the client's "Add Server" dialog
|
||||
|
||||
**1. Clone the repository**
|
||||
|
||||
```bash
|
||||
|
|
@ -112,56 +94,13 @@ git clone https://github.com/owpengram/owpengram-server.git
|
|||
cd owpengram-server
|
||||
```
|
||||
|
||||
**2. Run the launcher**
|
||||
|
||||
```bash
|
||||
./owpengram-server.sh # Linux
|
||||
```
|
||||
```powershell
|
||||
.\owpengram-server.bat # Windows
|
||||
```
|
||||
|
||||
The launcher checks what the server needs — Go 1.25+, Python 3, Docker,
|
||||
OpenSSL — and **installs whatever is missing** instead of handing you a
|
||||
shopping list: `scripts/install-prereqs.sh` on Arch and Ubuntu/Debian (asks for
|
||||
root once, then works unattended) or `scripts/install-prereqs.ps1` on Windows
|
||||
via winget. Run either directly with `--dry-run` to see what it would install
|
||||
without touching anything.
|
||||
|
||||
> Docker on Windows is the one thing the script will not install for you: its
|
||||
> containers are Linux images, so the daemon needs Docker Desktop's WSL2
|
||||
> backend. The launcher reports it with a link instead of starting it.
|
||||
|
||||
**3. Answer the first-run form**
|
||||
|
||||
With the prerequisites in place the launcher opens the server panel. On a fresh
|
||||
clone it shows a short form instead of the menu — only the values that need a
|
||||
human decision, with `.env.example` defaults for everything else; the admin API
|
||||
token and session key are generated for you. Confirm it and the panel writes
|
||||
`.env`, starts PostgreSQL/Redis, builds both binaries, runs them, and shows the
|
||||
admin panel address and password ready to copy.
|
||||
|
||||
**4. Finish setup in the browser**
|
||||
|
||||
Open that address. On a fresh install the panel opens a **web setup wizard**
|
||||
that walks through the server name, description and icon, the public address
|
||||
clients will connect to, the optional Bot API gateway, and your own operator
|
||||
account — then restarts the server so it all takes effect. Nothing has to be
|
||||
hand-edited to get going.
|
||||
|
||||
<details>
|
||||
<summary><b>🔧 Prefer to do it manually? (click to expand)</b></summary>
|
||||
|
||||
Requirements: **Go 1.25+**, **Docker** (or Docker Desktop) for PostgreSQL and
|
||||
Redis, and OpenSSL.
|
||||
|
||||
**Start the infrastructure** (PostgreSQL + Redis)
|
||||
**2. Start the infrastructure** (PostgreSQL + Redis)
|
||||
|
||||
```powershell
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
**Build and run the server**
|
||||
**3. Build and run the server**
|
||||
|
||||
Windows (PowerShell):
|
||||
|
||||
|
|
@ -184,86 +123,42 @@ workers in the same process.
|
|||
|
||||
> **Default local login code:** `12345` — change it before any real use!
|
||||
|
||||
Without the web wizard you also have to fill in `.env` yourself; see
|
||||
"⚙️ Configuration" below and [`.env.example`](.env.example).
|
||||
> 💡 **Prefer a menu over the command line?** Steps 2 and 3 above (Docker
|
||||
> infrastructure, build, run) can be done through the bundled **TUI server
|
||||
> panel** instead — see "🖥️ Server Panel" right below.
|
||||
|
||||
</details>
|
||||
### 🖥️ Server Panel (optional)
|
||||
|
||||
### 🖥️ Server Panel
|
||||
A cross-platform interactive TUI wraps the steps above — Docker naming
|
||||
migration, `docker compose up`, `go build`, and launching both
|
||||
`owpengram-server` and `owpengram-admin-panel` — behind a menu, so you don't
|
||||
re-run commands from scratch every time.
|
||||
|
||||
The launcher from step 2 is also a cross-platform interactive TUI: once the
|
||||
prerequisites are in place it drops into a menu that wraps everything above —
|
||||
Docker naming migration, `docker compose up`, `go build`, and launching both
|
||||
`owpengram-server` and `owpengram-admin-panel` — so you don't re-run commands
|
||||
from scratch every time.
|
||||
```bash
|
||||
./owpengram-server.sh # Linux/macOS
|
||||
```
|
||||
```powershell
|
||||
.\owpengram-server.bat # Windows
|
||||
```
|
||||
|
||||
Both launchers check prerequisites first (Go, Python 3, and the panel's own
|
||||
dependencies via `tui-panel/requirements-panel.txt`), then start the panel.
|
||||
|
||||
What it does:
|
||||
|
||||
- 🧙 **First-run setup** — the short `.env` form from step 3. It is the
|
||||
only thing the panel offers on a fresh clone; once `.env` exists the menu
|
||||
below replaces it, and later changes go through the `.env` editor.
|
||||
- 🧙 **First-run setup wizard** — walks through the required `.env` values
|
||||
before the first start.
|
||||
- ▶️ **Start / Stop / Restart** — launches `owpengram-server` and
|
||||
`owpengram-admin-panel` as detached background processes; closing the panel
|
||||
does **not** stop them, only "Stop" does. Reopening the panel later picks
|
||||
the same processes back up and reports live status.
|
||||
- ⬆️ **Update** — `git pull --ff-only`, rebuilds both binaries, restarts them,
|
||||
and re-execs the panel itself so it also picks up any change to its own
|
||||
code — one menu action instead of a manual pull/build/restart sequence. The
|
||||
web panel has the same action, with a dry run that reports what an update
|
||||
would do before anything is applied.
|
||||
code — one menu action instead of a manual pull/build/restart sequence.
|
||||
- 📜 **Live log viewer** — tail either binary's log, or both in a split view.
|
||||
- ⚙️ **`.env` editor** — edit configuration from inside the panel, grouped by
|
||||
feature, without hand-editing the file.
|
||||
|
||||
### 🏷️ Version and build
|
||||
|
||||
The admin panel's sidebar footer identifies exactly what is running:
|
||||
|
||||
```text
|
||||
Version: O7
|
||||
API layers: 225-229
|
||||
Build: 7ad68c3
|
||||
```
|
||||
|
||||
- **Version** — the OwpenGram server release line (`O7`).
|
||||
- **API layers** — every MTProto TL schema layer this binary can talk, read
|
||||
straight from the compatibility profiles rather than hardcoded.
|
||||
- **Build** — the short commit the binary was built from, stamped
|
||||
automatically by Go's VCS info (no special build flags needed); a trailing
|
||||
`+` means it was built from a working tree with uncommitted changes. Hover
|
||||
it for the full hash.
|
||||
|
||||
Quote the `Version` / `Build` pair in bug reports — it pins the exact code,
|
||||
which a release tag alone does not.
|
||||
|
||||
### 👥 Operators and permissions
|
||||
|
||||
The panel is no longer one shared password. Its **Operators** page creates
|
||||
named accounts, each with its own login and an explicit set of permissions,
|
||||
granted per section and per level — for example `accounts.read`
|
||||
vs `accounts.manage`, `storage.read` vs `storage.manage`, `broadcasts.send`,
|
||||
`moderation.review`, `server.manage`, `admins.manage`. An operator only sees
|
||||
the sections they hold a permission for; reaching anything else lands on a
|
||||
clear 403 screen instead of an empty page.
|
||||
|
||||
A **Full access** checkbox at the top of the permission grid grants the `*`
|
||||
wildcard — everything, including permissions added by future releases. It is
|
||||
deliberately separate from the grid (which is disabled while it is on), so
|
||||
"this person is a full admin" and "this person may do these six things" never
|
||||
get confused.
|
||||
|
||||
The one thing the panel will not let you do is lock yourself out: removing
|
||||
`admins.manage` from the last enabled operator who holds it — by editing,
|
||||
disabling, or deleting them — is refused, whether the grant is explicit or via
|
||||
the wildcard. There is always someone left who can manage operators.
|
||||
|
||||
Your first operator is created by the web setup wizard. Before it exists, the
|
||||
panel lets you in with a password it generates for that one purpose — and stops
|
||||
accepting that generated password the moment the wizard finishes. A password you
|
||||
set yourself (in Server Settings, or `TELESRV_ADMIN_UI_PASSWORD` in `.env`)
|
||||
keeps working as a full-access break-glass login alongside the operator
|
||||
accounts, so leave it unset or treat it like a root password.
|
||||
|
||||
### ⚙️ Configuration
|
||||
|
||||
[`.env.example`](.env.example) is the complete configuration reference — every
|
||||
|
|
@ -361,17 +256,6 @@ Related toggles (defaults in `.env.example`'s Advanced section): a low-space
|
|||
guard that rejects new uploads once storage nears full, and automatic
|
||||
cleanup of media no longer referenced by any message.
|
||||
|
||||
**Storage management in the panel.** The admin panel's Storage page puts the
|
||||
rest of this behind a UI: a usage breakdown per media type, an upload size cap
|
||||
(`TELESRV_STORAGE_MAX_UPLOAD_FILE_BYTES`, validated against the protocol's own
|
||||
upload ceiling), retention rules that can expire media globally or per type
|
||||
(`TELESRV_STORAGE_RETENTION_MODE` and the `TELESRV_STORAGE_RETENTION_MAX_AGE*`
|
||||
family), and a "danger zone" for manual purges by media category and age.
|
||||
|
||||
Destructive panel actions — purge, update, restart — all go through the same
|
||||
three-step flow: type a reason, run a **dry run** that reports exactly what
|
||||
would happen, then confirm. Nothing irreversible fires on a single click.
|
||||
|
||||
## 🔌 Ports to open
|
||||
|
||||
When deploying on a public server, open the following according to the
|
||||
|
|
@ -433,47 +317,37 @@ the public routes to it with HTTPS.
|
|||
## 📱 Connect a client
|
||||
|
||||
Use the OwpenGram clients, which have a built-in **Add Server** option on the
|
||||
server-selection screen at login — no source patching or custom build needed.
|
||||
They also ship with our public server already in that list, so a client is
|
||||
useful the moment it is installed, with or without a server of your own.
|
||||
Both are forks of the official apps, kept on the same TL layer as the server and
|
||||
rebased on the upstream release that introduced it:
|
||||
server-selection screen at login — no source patching or custom build needed:
|
||||
|
||||
| Client | Upstream base | Upstream commit | TL layer |
|
||||
|---|---|---|---|
|
||||
| 💻 [Desktop](https://github.com/owpengram/owpengram-desktop-client) | Telegram Desktop `v7.2.2` | `7b4481b6941212bb9dbf08e533adea97947b0f44` | 229 |
|
||||
| 🤖 [Android](https://github.com/owpengram/owpengram-android-client) | Telegram for Android `v12.10.1` | `62b56a07ca7e30e39f7fd00a6728d6bbd716ca1c` | 229 |
|
||||
- 🤖 [Android client](https://github.com/owpengram/owpengram-android-client)
|
||||
- 💻 [Desktop client](https://github.com/owpengram/owpengram-desktop-client)
|
||||
|
||||
A stock Telegram client will not connect, since it only trusts Telegram's own
|
||||
DC list and RSA keys. The server's canonical layer is 229, with exact
|
||||
compatibility profiles for layers 225-229 — so an older fork build keeps
|
||||
working after the server moves forward. Locally that server is
|
||||
`127.0.0.1:2398`, DC id `2`.
|
||||
DC list and RSA keys.
|
||||
|
||||
**All you need is `host:port`.** On the login screen open server selection →
|
||||
**Add Server** and type the address (e.g. `chat.example.com:2398`, or
|
||||
`192.168.1.50:2398`). The client fetches `/owpengram/server-info` from the same
|
||||
port and fills in the rest by itself — RSA public key, DC id, and the server's
|
||||
name, description and icon as you set them in the setup wizard. No
|
||||
`openssl`, no PEM copy-paste.
|
||||
- Telegram Desktop commit: `9caf32dffc90ddd9bb08ad5777b865f729fa167b`
|
||||
- Canonical TL layer: 228
|
||||
- Exact compatibility profiles: Layer 225-228
|
||||
- Local DC: `127.0.0.1:2398`, DC id `2`
|
||||
|
||||
The RSA key and DC id are still there under **Advanced** if you want to check
|
||||
or override them. To get the key by hand — for an air-gapped machine, or to
|
||||
verify what the client fetched — export it from the server's private key:
|
||||
**1. Export your server's public key**
|
||||
|
||||
```bash
|
||||
After the server generates `data/server_rsa.pem`, export the matching public
|
||||
key as PEM:
|
||||
|
||||
```powershell
|
||||
openssl rsa -in data/server_rsa.pem -RSAPublicKey_out -out data/server_rsa.pub
|
||||
```
|
||||
|
||||
or just read the JSON the client reads:
|
||||
**2. Add the server in the client**
|
||||
|
||||
```bash
|
||||
curl http://your-server:2398/owpengram/server-info
|
||||
```
|
||||
On the login screen, open server selection → **Add Server**, and fill in:
|
||||
|
||||
> Self-configuration rides the same-port HTTP side that also serves the
|
||||
> WebSocket transport, so it needs `TELESRV_WEBSOCKET_ENABLE=true` (the
|
||||
> default) and opens no extra port. With it off, fill in **Advanced** manually.
|
||||
- **Host** — your server's address (e.g. `192.168.1.50` or `chat.example.com`)
|
||||
- **Port** — `2398` by default
|
||||
- **Main data center** — the DC id from `TELESRV_DC` (`2` by default)
|
||||
- **RSA Public Key** — paste the full contents of `data/server_rsa.pub`
|
||||
(the `-----BEGIN RSA PUBLIC KEY-----...` PEM block) into the key field
|
||||
|
||||
## 🧪 Development: multi-device smoke test
|
||||
|
||||
|
|
@ -500,21 +374,16 @@ you changed `TELESRV_DEV_AUTH_CODE`. Recommended checks:
|
|||
## 📂 Repository layout
|
||||
|
||||
```text
|
||||
owpengram-server.sh/.bat one-command launcher (installs prerequisites, then the panel)
|
||||
scripts/install-prereqs.* unattended prerequisite installers (Arch/Ubuntu, Windows)
|
||||
cmd/telesrv/ server entrypoint
|
||||
cmd/telesrv-admin/ admin backend and embedded React web UI (incl. the setup wizard)
|
||||
cmd/telesrv-update/ one-click update helper used by the panels
|
||||
cmd/telesrv-admin/ admin backend and web UI
|
||||
tui-panel/ interactive TUI server panel (setup, start/stop, update, logs, .env editor)
|
||||
deploy/ docker-compose (incl. MinIO), migrations, deploy helpers
|
||||
data/ bundled language packs and optional seed data
|
||||
internal/mtprotoedge/ MTProto transport, auth key, session, ack/resend, server-info endpoints
|
||||
internal/mtprotoedge/ MTProto transport, auth key, session, ack/resend
|
||||
internal/rpc/ TL router and client compatibility handlers
|
||||
internal/app/ domain services
|
||||
internal/domain/ protocol-independent domain models
|
||||
internal/store/ memory/postgres/redis storage backends
|
||||
internal/identity/ admin-editable server name, description, and icon
|
||||
internal/botapi/ minimal HTTP Bot API gateway
|
||||
internal/seed/ bundled seed catalog loaders
|
||||
internal/sfu/ real-time SFU experiments
|
||||
internal/turnsrv/ TURN/STUN building blocks
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// handleAddServerLinkAPI builds an owpg://addserver link (see the desktop
|
||||
// and Android clients' handling of that scheme) carrying only this server's
|
||||
// host and port, so an operator can hand it out as a ready-made "add my
|
||||
// server" button/QR code.
|
||||
//
|
||||
// Deliberately carries nothing else -- no name, description, key, or DC.
|
||||
// Anyone who can get a link in front of a user (a forum post, a chat
|
||||
// message, an intercepted share) controls whatever it contains; if it also
|
||||
// carried the RSA key, a link with a forged key pointed at an attacker's own
|
||||
// host would be indistinguishable from a real one, and the client would
|
||||
// trust it outright as "this server's identity" -- a real MITM vector, not
|
||||
// a hypothetical one. host+port alone can't misrepresent anything: the
|
||||
// client always fetches name/description/key/DC itself, straight from
|
||||
// whatever actually answers at that address (ServerInfoPath), the same way
|
||||
// it already does for a hand-typed address in the "Add Server" form.
|
||||
func (s *server) handleAddServerLinkAPI(w http.ResponseWriter, r *http.Request) {
|
||||
q := url.Values{}
|
||||
q.Set("host", s.cfg.AdvertiseHost)
|
||||
q.Set("port", strconv.Itoa(s.cfg.ServerPort))
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"link": "owpg://addserver?" + q.Encode(),
|
||||
})
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// bcryptCost is deliberately above bcrypt.DefaultCost (10). A panel login is a
|
||||
// once-per-shift operation, so the extra time is invisible to an operator and
|
||||
// meaningful to anyone working through a stolen dump of the table.
|
||||
const bcryptCost = 12
|
||||
|
||||
// dummyBcryptHash is compared against when no account matched, so a login
|
||||
// attempt costs the same whether or not the username exists. Without it the
|
||||
// response time alone answers "is there an operator called X" -- the exact
|
||||
// question the uniform error message refuses to answer.
|
||||
//
|
||||
// Value is bcrypt of a random string at bcryptCost; nothing authenticates
|
||||
// against it.
|
||||
const dummyBcryptHash = "$2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
|
||||
|
||||
// breakGlassUsername is the name of the built-in operator backed by
|
||||
// TELESRV_ADMIN_UI_PASSWORD / _TOKEN rather than by a database row.
|
||||
//
|
||||
// It is a real name rather than "no name" so audit lines read as an operator
|
||||
// instead of as a blank, and so signing in as it is an explicit act: a blank
|
||||
// username authenticates nothing.
|
||||
//
|
||||
// A database account may not take this name: authenticateLogin resolves it to
|
||||
// the environment credential before ever consulting the table, so a row called
|
||||
// "owpengram" would be shadowed -- and a name that silently does nothing is a
|
||||
// trap. createAdminConsoleUser rejects it outright.
|
||||
const breakGlassUsername = "owpengram"
|
||||
|
||||
// loginIdentity is who a successful login turns out to be.
|
||||
type loginIdentity struct {
|
||||
actor string
|
||||
userID int64
|
||||
epoch int32
|
||||
permissions []string
|
||||
}
|
||||
|
||||
// authenticateLogin resolves a login request to an identity, or reports
|
||||
// failure. It never distinguishes its failure modes to the caller: every one
|
||||
// of them is a plain false, so the handler cannot accidentally leak which.
|
||||
func (s *server) authenticateLogin(ctx context.Context, req loginRequest) (loginIdentity, bool) {
|
||||
username := strings.TrimSpace(req.Username)
|
||||
|
||||
// A username is always required. An empty one used to resolve to the
|
||||
// break-glass operator, which made a blank field an unnamed second route to
|
||||
// the highest-privilege login -- the sort of thing that does not belong in
|
||||
// an admin panel. The operator must now be asked for by name.
|
||||
if username == "" {
|
||||
return loginIdentity{}, false
|
||||
}
|
||||
|
||||
// The break-glass operator. Intentionally not backed by the database so it
|
||||
// still works when the database does not.
|
||||
if strings.EqualFold(username, breakGlassUsername) {
|
||||
if !s.validSecret(req.Secret) {
|
||||
return loginIdentity{}, false
|
||||
}
|
||||
return loginIdentity{actor: breakGlassUsername, permissions: s.cfg.Permissions}, true
|
||||
}
|
||||
|
||||
if s.read == nil {
|
||||
return loginIdentity{}, false
|
||||
}
|
||||
cred, err := s.read.AdminConsoleCredentialByUsername(ctx, username)
|
||||
if err != nil {
|
||||
if !errors.Is(err, errAdminUserNotFound) {
|
||||
return loginIdentity{}, false
|
||||
}
|
||||
// Burn the same work an existing account would have cost before
|
||||
// answering, so "no such user" and "wrong password" take equal time.
|
||||
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(req.Secret))
|
||||
return loginIdentity{}, false
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(cred.PasswordHash), []byte(req.Secret)) != nil {
|
||||
return loginIdentity{}, false
|
||||
}
|
||||
// Checked after the hash comparison on purpose: answering "disabled"
|
||||
// faster than "wrong password" would confirm the account exists to someone
|
||||
// who does not know its password.
|
||||
if !cred.Enabled {
|
||||
return loginIdentity{}, false
|
||||
}
|
||||
return loginIdentity{
|
||||
actor: cred.Username,
|
||||
userID: cred.ID,
|
||||
epoch: cred.TokenEpoch,
|
||||
permissions: cred.Permissions,
|
||||
}, true
|
||||
}
|
||||
|
||||
// hashAdminPassword validates a new password and returns its bcrypt hash.
|
||||
func hashAdminPassword(password string) (string, error) {
|
||||
if err := validateAdminPassword(password); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// validateAdminPassword deliberately imposes no length floor and no
|
||||
// composition rule: the operator picks the password.
|
||||
//
|
||||
// The two checks that remain are not policy. A blank password is not a weak
|
||||
// password, it is no password -- anyone who learns the username is in. And
|
||||
// bcrypt silently ignores everything past 72 bytes, so a longer one is refused
|
||||
// rather than quietly truncated to something the operator did not choose and
|
||||
// cannot reproduce.
|
||||
func validateAdminPassword(password string) error {
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return errPasswordBlank
|
||||
}
|
||||
if len([]byte(password)) > 72 {
|
||||
return errPasswordTooLong
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
errPasswordTooLong = errors.New("password must be at most 72 bytes")
|
||||
errPasswordBlank = errors.New("password must not be blank")
|
||||
errUsernameInvalid = errors.New("username must be 3-64 characters: letters, digits, dot, dash or underscore")
|
||||
)
|
||||
|
||||
// validateAdminUsername keeps usernames to a shape that reads the same
|
||||
// everywhere it is displayed. Anything outside it -- spaces, control
|
||||
// characters, look-alike unicode -- is refused rather than normalised, since a
|
||||
// username that renders differently from what is stored is a way to be
|
||||
// mistaken for another operator.
|
||||
func validateAdminUsername(username string) error {
|
||||
if n := len([]rune(username)); n < 3 || n > 64 {
|
||||
return errUsernameInvalid
|
||||
}
|
||||
for _, r := range username {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', unicode.IsDigit(r):
|
||||
case r == '.', r == '-', r == '_':
|
||||
default:
|
||||
return errUsernameInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"telesrv/internal/identity"
|
||||
)
|
||||
|
||||
func TestValidateAdminPassword(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
password string
|
||||
want error
|
||||
}{
|
||||
{"ok", "correct horse battery", nil},
|
||||
// No length floor: the operator picks the password, however short.
|
||||
{"a single character", "x", nil},
|
||||
{"blank", " ", errPasswordBlank},
|
||||
{"empty", "", errPasswordBlank},
|
||||
// bcrypt truncates silently past 72 bytes, so anything longer must be
|
||||
// refused rather than accepted as a password the operator did not set.
|
||||
{"past bcrypt's input limit", strings.Repeat("a", 73), errPasswordTooLong},
|
||||
{"73 bytes of multibyte runes", strings.Repeat("é", 37), errPasswordTooLong},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateAdminPassword(tc.password)
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Fatalf("validateAdminPassword(%q) = %v, want %v", tc.password, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAdminUsername(t *testing.T) {
|
||||
valid := []string{"admin", "ops.lead", "on-call_2", strings.Repeat("a", 64)}
|
||||
for _, username := range valid {
|
||||
if err := validateAdminUsername(username); err != nil {
|
||||
t.Errorf("validateAdminUsername(%q) = %v, want nil", username, err)
|
||||
}
|
||||
}
|
||||
invalid := []string{
|
||||
"",
|
||||
"ab", // under the floor
|
||||
strings.Repeat("a", 65), // over the ceiling
|
||||
"has space",
|
||||
"with\ttab",
|
||||
"with\nnewline",
|
||||
"аdmin", // Cyrillic 'а': renders like "admin" but is a different operator
|
||||
"admin*", // permission wildcard has no business in a name
|
||||
"a@b",
|
||||
}
|
||||
for _, username := range invalid {
|
||||
if err := validateAdminUsername(username); !errors.Is(err, errUsernameInvalid) {
|
||||
t.Errorf("validateAdminUsername(%q) = %v, want errUsernameInvalid", username, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashAdminPasswordRoundTrips(t *testing.T) {
|
||||
const password = "a sufficiently long password"
|
||||
hash, err := hashAdminPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("hashAdminPassword: %v", err)
|
||||
}
|
||||
if strings.Contains(hash, password) {
|
||||
t.Fatal("hash contains the plaintext")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||
t.Fatalf("hash does not verify against its own password: %v", err)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password+"x")); err == nil {
|
||||
t.Fatal("hash verified against the wrong password")
|
||||
}
|
||||
if cost, err := bcrypt.Cost([]byte(hash)); err != nil || cost != bcryptCost {
|
||||
t.Fatalf("cost = %d (err %v), want %d", cost, err, bcryptCost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashAdminPasswordRejectsInvalid(t *testing.T) {
|
||||
// A short password is fine; an absent one is not.
|
||||
if _, err := hashAdminPassword("x"); err != nil {
|
||||
t.Fatalf("a one-character password was refused: %v", err)
|
||||
}
|
||||
if _, err := hashAdminPassword(" "); !errors.Is(err, errPasswordBlank) {
|
||||
t.Fatalf("err = %v, want errPasswordBlank", err)
|
||||
}
|
||||
if _, err := hashAdminPassword(strings.Repeat("a", 73)); !errors.Is(err, errPasswordTooLong) {
|
||||
t.Fatalf("err = %v, want errPasswordTooLong", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The dummy hash exists so a login against an unknown username costs the same
|
||||
// bcrypt work as a real one. If it were malformed, CompareHashAndPassword would
|
||||
// return early and hand back the timing signal it is there to remove.
|
||||
func TestDummyBcryptHashIsWellFormedAndUnusable(t *testing.T) {
|
||||
cost, err := bcrypt.Cost([]byte(dummyBcryptHash))
|
||||
if err != nil {
|
||||
t.Fatalf("dummy hash is not a valid bcrypt hash: %v", err)
|
||||
}
|
||||
if cost != bcryptCost {
|
||||
t.Fatalf("dummy hash cost = %d, want %d -- it must cost the same as a real one", cost, bcryptCost)
|
||||
}
|
||||
for _, guess := range []string{"", "password", "admin", dummyBcryptHash} {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(guess)); err == nil {
|
||||
t.Fatalf("dummy hash authenticated %q", guess)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalisePermissions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
want []string
|
||||
}{
|
||||
{"trims and drops empties", []string{" a ", "", " ", "b"}, []string{"a", "b"}},
|
||||
{"de-duplicates", []string{"a", "a", "b", "a"}, []string{"a", "b"}},
|
||||
// A stored list that both names the wildcard and lists rights would read
|
||||
// narrower than it actually is wherever it is displayed.
|
||||
{"wildcard collapses everything", []string{"a", "*", "b"}, []string{permissionAll}},
|
||||
{"wildcard alone", []string{"*"}, []string{permissionAll}},
|
||||
{"empty stays empty", []string{}, []string{}},
|
||||
{"only blanks", []string{"", " "}, []string{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := normalisePermissions(tc.in)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("normalisePermissions(%v) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("normalisePermissions(%v) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assignablePermissions drives the account editor. Offering "*" there would let
|
||||
// a click hand out every right including admins.manage, which is exactly what
|
||||
// the per-permission list exists to make deliberate.
|
||||
func TestAssignablePermissionsExcludesWildcard(t *testing.T) {
|
||||
for _, p := range assignablePermissions() {
|
||||
if p == permissionAll {
|
||||
t.Fatal("assignablePermissions offers the wildcard")
|
||||
}
|
||||
}
|
||||
var sawAdminsManage bool
|
||||
for _, p := range assignablePermissions() {
|
||||
if p == permissionAdminsManage {
|
||||
sawAdminsManage = true
|
||||
}
|
||||
}
|
||||
if !sawAdminsManage {
|
||||
t.Fatal("assignablePermissions omits admins.manage, so it could never be granted")
|
||||
}
|
||||
}
|
||||
|
||||
// A blank username must never authenticate, even with the correct break-glass
|
||||
// secret. It briefly did, which made an empty field an unnamed second route to
|
||||
// the highest-privilege login; the operator has to be asked for by name.
|
||||
func TestBlankUsernameNeverAuthenticates(t *testing.T) {
|
||||
s := &server{cfg: uiConfig{Password: "letmein", Permissions: []string{permissionAll}}}
|
||||
for _, username := range []string{"", " ", "\t"} {
|
||||
if _, ok := s.authenticateLogin(t.Context(), loginRequest{Username: username, Secret: "letmein"}); ok {
|
||||
t.Fatalf("blank username %q authenticated", username)
|
||||
}
|
||||
}
|
||||
// The same secret under the operator's actual name still works, so the
|
||||
// check above is refusing the blank name rather than the credential.
|
||||
identity, ok := s.authenticateLogin(t.Context(), loginRequest{Username: breakGlassUsername, Secret: "letmein"})
|
||||
if !ok {
|
||||
t.Fatal("the break-glass operator could not sign in by name")
|
||||
}
|
||||
if identity.actor != breakGlassUsername {
|
||||
t.Fatalf("actor = %q, want %q", identity.actor, breakGlassUsername)
|
||||
}
|
||||
if identity.userID != 0 {
|
||||
t.Fatalf("userID = %d, want 0 -- the break-glass operator has no database row", identity.userID)
|
||||
}
|
||||
}
|
||||
|
||||
// Case is not a way to get a different operator: the name resolves to the
|
||||
// break-glass login however it is typed, matching the case-insensitive unique
|
||||
// index that named accounts live under.
|
||||
func TestBreakGlassUsernameIsCaseInsensitive(t *testing.T) {
|
||||
s := &server{cfg: uiConfig{Password: "letmein", Permissions: []string{permissionAll}}}
|
||||
for _, username := range []string{"owpengram", "OwpenGram", "OWPENGRAM", " owpengram "} {
|
||||
identity, ok := s.authenticateLogin(t.Context(), loginRequest{Username: username, Secret: "letmein"})
|
||||
if !ok {
|
||||
t.Fatalf("%q did not resolve to the break-glass operator", username)
|
||||
}
|
||||
if identity.actor != breakGlassUsername {
|
||||
t.Fatalf("%q signed in as %q", username, identity.actor)
|
||||
}
|
||||
}
|
||||
if _, ok := s.authenticateLogin(t.Context(), loginRequest{Username: breakGlassUsername, Secret: "wrong"}); ok {
|
||||
t.Fatal("the break-glass operator authenticated with the wrong secret")
|
||||
}
|
||||
}
|
||||
|
||||
// The break-glass password quickstart generates for the very first login
|
||||
// must stop authenticating once the first-run wizard is done, but a
|
||||
// password an operator actually chose -- even one that happens to still be
|
||||
// sitting in .env from before the wizard finished -- must never be
|
||||
// affected by that. This is the actual integration point between
|
||||
// validSecret and identity.Store; the package's own tests cover
|
||||
// SetupPending/TemporaryPasswordMatches in isolation.
|
||||
func TestValidSecretRetiresOnlyTheGeneratedPassword(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := identity.NewStore(dir)
|
||||
s := &server{cfg: uiConfig{Password: "generated-once", Permissions: []string{permissionAll}}, identity: store}
|
||||
|
||||
// No marker written at all yet (identity.Store's zero state) -- the
|
||||
// password behaves like an ordinary one an operator set.
|
||||
if !s.validSecret("generated-once") {
|
||||
t.Fatal("password should authenticate before any wizard marker exists")
|
||||
}
|
||||
|
||||
// Bootstrap-style: the setup-pending marker plus the matching
|
||||
// temporary-password marker, exactly as tui-panel/server-panel.py's
|
||||
// bootstrap_env() writes them on a fresh install.
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, ".setup_pending"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, ".admin_password_temporary"), []byte("generated-once"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.validSecret("generated-once") {
|
||||
t.Fatal("the generated password must keep working while the wizard is still pending")
|
||||
}
|
||||
|
||||
// Wizard finishes: MarkSetupComplete removes both markers.
|
||||
if err := store.MarkSetupComplete(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.validSecret("generated-once") {
|
||||
t.Fatal("the generated password must stop authenticating once setup is complete")
|
||||
}
|
||||
|
||||
// An operator-chosen password behaves normally regardless: setting a
|
||||
// new .env value (this test's stand-in for that) authenticates whether
|
||||
// or not a wizard ever ran, because it never matches either marker.
|
||||
s.cfg.Password = "an-operator-actually-chose-this"
|
||||
if !s.validSecret("an-operator-actually-chose-this") {
|
||||
t.Fatal("an operator-chosen password must authenticate after setup completion, same as always")
|
||||
}
|
||||
}
|
||||
|
||||
// A session for a named account must not be trusted on the strength of its
|
||||
// signature alone: the account's rights are re-read per request, and a nil read
|
||||
// store has to fail closed rather than fall back to the claims.
|
||||
func TestCurrentSessionPermissionsFailsClosedWithoutStore(t *testing.T) {
|
||||
s := &server{}
|
||||
if _, ok := s.currentSessionPermissions(t.Context(), sessionClaims{
|
||||
UserID: 7,
|
||||
Epoch: 1,
|
||||
Permissions: []string{permissionAll},
|
||||
}); ok {
|
||||
t.Fatal("a named-account session was accepted with no store to verify it against")
|
||||
}
|
||||
}
|
||||
|
||||
// The break-glass operator has no row to re-read, so it keeps the configured
|
||||
// rights -- that login is the way back in when the database is unreachable.
|
||||
func TestCurrentSessionPermissionsAllowsBreakGlass(t *testing.T) {
|
||||
s := &server{}
|
||||
perms, ok := s.currentSessionPermissions(t.Context(), sessionClaims{
|
||||
UserID: 0,
|
||||
Permissions: []string{permissionServerManage},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("break-glass session rejected")
|
||||
}
|
||||
if !perms.Has(permissionServerManage) {
|
||||
t.Fatal("break-glass session lost its configured permission")
|
||||
}
|
||||
if perms.Has(permissionAdminsManage) {
|
||||
t.Fatal("break-glass session gained a permission it was not configured with")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// AdminConsoleUser is one named panel operator. It deliberately never carries
|
||||
// the password hash outside authentication: everything that renders or returns
|
||||
// a user uses this shape, so a hash cannot leak into an API response by
|
||||
// someone adding a field to a JSON struct.
|
||||
type AdminConsoleUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TokenEpoch int32 `json:"token_epoch"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
}
|
||||
|
||||
// adminConsoleCredential is the authentication-only view: the hash plus the
|
||||
// few fields a login decision needs. Kept unexported and separate from
|
||||
// AdminConsoleUser so the hash has exactly one reason to be read.
|
||||
type adminConsoleCredential struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
Permissions []string
|
||||
Enabled bool
|
||||
TokenEpoch int32
|
||||
}
|
||||
|
||||
// errAdminUserNotFound is returned instead of pgx.ErrNoRows so callers can
|
||||
// treat "no such operator" without importing pgx.
|
||||
var errAdminUserNotFound = errors.New("admin console user not found")
|
||||
|
||||
const adminConsoleUserColumns = `id, username, permissions, enabled, token_epoch, created_at, updated_at, last_login_at`
|
||||
|
||||
// AdminConsoleCredentialByUsername loads the authentication view for a login
|
||||
// attempt. The lookup is case-insensitive to match the unique index, so an
|
||||
// operator cannot be shadowed by a differently-cased duplicate.
|
||||
func (s *readStore) AdminConsoleCredentialByUsername(ctx context.Context, username string) (adminConsoleCredential, error) {
|
||||
var out adminConsoleCredential
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, username, password_hash, permissions, enabled, token_epoch
|
||||
FROM admin_console_users
|
||||
WHERE lower(username) = lower($1)`, strings.TrimSpace(username)).Scan(
|
||||
&out.ID, &out.Username, &out.PasswordHash, &out.Permissions, &out.Enabled, &out.TokenEpoch)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return adminConsoleCredential{}, errAdminUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return adminConsoleCredential{}, fmt.Errorf("load admin console credential: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AdminConsoleSessionState re-reads the two things a live session depends on.
|
||||
// requireAuthAPI calls it per request so that disabling an operator, editing
|
||||
// their rights or changing their password takes effect immediately rather than
|
||||
// whenever their signed cookie happens to expire.
|
||||
func (s *readStore) AdminConsoleSessionState(ctx context.Context, id int64) (enabled bool, epoch int32, permissions []string, err error) {
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
SELECT enabled, token_epoch, permissions FROM admin_console_users WHERE id = $1`, id).
|
||||
Scan(&enabled, &epoch, &permissions)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, 0, nil, errAdminUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return false, 0, nil, fmt.Errorf("load admin console session state: %w", err)
|
||||
}
|
||||
return enabled, epoch, permissions, nil
|
||||
}
|
||||
|
||||
// ListAdminConsoleUsers returns every operator, newest last so the list reads
|
||||
// like the order they were added.
|
||||
func (s *readStore) ListAdminConsoleUsers(ctx context.Context) ([]AdminConsoleUser, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+adminConsoleUserColumns+` FROM admin_console_users ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list admin console users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []AdminConsoleUser{}
|
||||
for rows.Next() {
|
||||
var u AdminConsoleUser
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Permissions, &u.Enabled,
|
||||
&u.TokenEpoch, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt); err != nil {
|
||||
return nil, fmt.Errorf("scan admin console user: %w", err)
|
||||
}
|
||||
if u.Permissions == nil {
|
||||
u.Permissions = []string{}
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate admin console users: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CountEnabledAdminConsoleUsersWith reports how many enabled operators hold a
|
||||
// given permission, counting the '*' wildcard as holding everything. It exists
|
||||
// for the last-administrator guard: the panel refuses the edit that would
|
||||
// leave nobody able to manage operators.
|
||||
func (s *readStore) CountEnabledAdminConsoleUsersWith(ctx context.Context, permission string, excludeID int64) (int, error) {
|
||||
var n int
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*)::int FROM admin_console_users
|
||||
WHERE enabled
|
||||
AND id <> $2
|
||||
AND (permissions @> ARRAY[$1]::text[] OR permissions @> ARRAY['*']::text[])`,
|
||||
permission, excludeID).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count admin console users with permission: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
|
@ -1,400 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
)
|
||||
|
||||
// Operator accounts are written here rather than through callAdminAPI like the
|
||||
// domain mutations are, on purpose. They are not a Telegram entity: they are
|
||||
// the console's own authentication, and routing them through the domain API
|
||||
// would mean the console cannot fix its own locked-out operators whenever that
|
||||
// service is unreachable -- exactly when you need to. Reads already go straight
|
||||
// to Postgres for the same reason, so this keeps one owner for one table.
|
||||
//
|
||||
// They do follow the panel's command convention: every mutation is a
|
||||
// /api/actions/* route that takes a reason, runs as a dry run first and returns
|
||||
// an admin.CommandResult. Granting somebody the run of the console deserves the
|
||||
// same "here is what this will do, confirm it" step as freezing an account.
|
||||
|
||||
// requireAdminsManage is the single gate for every operator-account route, so
|
||||
// none of them can be registered without it by accident.
|
||||
func (s *server) requireAdminsManage(next http.Handler) http.Handler {
|
||||
return s.scopedRoute(permissionAdminsManage, next)
|
||||
}
|
||||
|
||||
// errAdminUsernameTaken maps the unique-index violation to something the panel
|
||||
// can show, without leaking the constraint name.
|
||||
var errAdminUsernameTaken = errors.New("username is already taken")
|
||||
|
||||
// errLastManagerStanding guards against an edit that would leave nobody able to
|
||||
// administer operators. The break-glass credential could still recover it, but
|
||||
// that is a recovery path, not a thing to walk into by accident.
|
||||
var errLastManagerStanding = errors.New("this would leave no enabled account able to manage operators")
|
||||
|
||||
// errUsernameReserved guards the break-glass name, which authentication
|
||||
// resolves before the table is consulted.
|
||||
var errUsernameReserved = errors.New("this username is reserved for the built-in operator")
|
||||
|
||||
// createAdminConsoleUser inserts a new operator. token_epoch starts at 1; there
|
||||
// are no sessions to invalidate yet.
|
||||
func (s *server) createAdminConsoleUser(ctx context.Context, username, password string, permissions []string, enabled bool) (AdminConsoleUser, error) {
|
||||
if err := validateAdminUsername(username); err != nil {
|
||||
return AdminConsoleUser{}, err
|
||||
}
|
||||
// authenticateLogin resolves this name to the environment credential before
|
||||
// it ever reaches the table, so a row by this name could never be logged
|
||||
// into. Refuse it rather than storing an account that silently does nothing.
|
||||
if strings.EqualFold(strings.TrimSpace(username), breakGlassUsername) {
|
||||
return AdminConsoleUser{}, errUsernameReserved
|
||||
}
|
||||
hash, err := hashAdminPassword(password)
|
||||
if err != nil {
|
||||
return AdminConsoleUser{}, err
|
||||
}
|
||||
permissions = normalisePermissions(permissions)
|
||||
|
||||
var u AdminConsoleUser
|
||||
err = s.read.pool.QueryRow(ctx, `
|
||||
INSERT INTO admin_console_users (username, password_hash, permissions, enabled)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING `+adminConsoleUserColumns,
|
||||
strings.TrimSpace(username), hash, permissions, enabled).
|
||||
Scan(&u.ID, &u.Username, &u.Permissions, &u.Enabled, &u.TokenEpoch,
|
||||
&u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt)
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return AdminConsoleUser{}, errAdminUsernameTaken
|
||||
}
|
||||
if err != nil {
|
||||
return AdminConsoleUser{}, fmt.Errorf("create admin console user: %w", err)
|
||||
}
|
||||
if u.Permissions == nil {
|
||||
u.Permissions = []string{}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// updateAdminConsoleUser changes permissions and/or enabled state.
|
||||
//
|
||||
// It deliberately does NOT move token_epoch. currentSessionPermissions re-reads
|
||||
// this row on every request, so a narrowed permission set applies from the
|
||||
// operator's next request and a disabled account is refused outright -- both
|
||||
// without ending a session. Bumping the epoch here would only sign someone out
|
||||
// mid-task to achieve what the re-read already achieves.
|
||||
//
|
||||
// A password change is different and does bump it: the password is not
|
||||
// re-checked per request, so nothing else would retire the old sessions.
|
||||
func (s *server) updateAdminConsoleUser(ctx context.Context, id int64, permissions []string, enabled bool) (AdminConsoleUser, error) {
|
||||
permissions = normalisePermissions(permissions)
|
||||
|
||||
var u AdminConsoleUser
|
||||
err := s.read.pool.QueryRow(ctx, `
|
||||
UPDATE admin_console_users
|
||||
SET permissions = $2,
|
||||
enabled = $3,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING `+adminConsoleUserColumns,
|
||||
id, permissions, enabled).
|
||||
Scan(&u.ID, &u.Username, &u.Permissions, &u.Enabled, &u.TokenEpoch,
|
||||
&u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return AdminConsoleUser{}, errAdminUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return AdminConsoleUser{}, fmt.Errorf("update admin console user: %w", err)
|
||||
}
|
||||
if u.Permissions == nil {
|
||||
u.Permissions = []string{}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// setAdminConsoleUserPassword replaces the hash and bumps the epoch, so a
|
||||
// password change signs out whoever was using the old one -- which is the
|
||||
// point of changing it after a suspected compromise.
|
||||
func (s *server) setAdminConsoleUserPassword(ctx context.Context, id int64, password string) error {
|
||||
hash, err := hashAdminPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := s.read.pool.Exec(ctx, `
|
||||
UPDATE admin_console_users
|
||||
SET password_hash = $2, token_epoch = token_epoch + 1, updated_at = now()
|
||||
WHERE id = $1`, id, hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set admin console user password: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errAdminUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalisePermissions trims, de-duplicates and collapses to the wildcard when
|
||||
// it is present, so "*" plus a list cannot be stored as something that reads
|
||||
// narrower than it is.
|
||||
func normalisePermissions(in []string) []string {
|
||||
seen := make(map[string]struct{}, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, p := range in {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if p == permissionAll {
|
||||
return []string{permissionAll}
|
||||
}
|
||||
if _, dup := seen[p]; dup {
|
||||
continue
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- HTTP surface -----------------------------------------------------------
|
||||
|
||||
// adminUserActionRequest carries the panel's usual command envelope alongside
|
||||
// the operator fields. ID is absent when creating.
|
||||
type adminUserActionRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *server) handleListAdminUsersAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
users, err := s.read.ListAdminConsoleUsers(r.Context())
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
// The built-in operator has no database row, so it would otherwise be
|
||||
// invisible here -- a list of who can sign in that omits the account
|
||||
// with the most rights is worse than no list. It is reported first and
|
||||
// flagged as system; the panel renders it read-only, and every mutation
|
||||
// below refuses it anyway.
|
||||
"system": map[string]any{
|
||||
"username": breakGlassUsername,
|
||||
"permissions": newPanelPermissions(s.cfg.Permissions).List(),
|
||||
"enabled": true,
|
||||
"system": true,
|
||||
},
|
||||
"rows": users,
|
||||
// The vocabulary the panel offers when editing an account, so the list
|
||||
// of assignable rights lives in one place instead of being duplicated
|
||||
// in the frontend and drifting from what the routes actually check.
|
||||
"available_permissions": assignablePermissions(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreateAdminUserAPI runs as a dry run unless confirmed.
|
||||
func (s *server) handleCreateAdminUserAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body adminUserActionRequest
|
||||
if !s.decodeAdminUserAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "admin-operator-create")
|
||||
enabled := body.Enabled == nil || *body.Enabled
|
||||
permissions := normalisePermissions(body.Permissions)
|
||||
|
||||
// Validate on the dry run too, so "this will fail" is discovered before the
|
||||
// operator is asked to confirm rather than after.
|
||||
if err := validateAdminUsername(strings.TrimSpace(body.Username)); err != nil {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: "create-admin-operator"}, err)
|
||||
return
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(body.Username), breakGlassUsername) {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: "create-admin-operator"}, errUsernameReserved)
|
||||
return
|
||||
}
|
||||
if err := validateAdminPassword(body.Password); err != nil {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: "create-admin-operator"}, err)
|
||||
return
|
||||
}
|
||||
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, admin.CommandResult{
|
||||
CommandID: meta.CommandID,
|
||||
Action: "create-admin-operator",
|
||||
Status: "ok",
|
||||
DryRun: true,
|
||||
Message: fmt.Sprintf("Would create operator %q with %d permission(s), %s.",
|
||||
strings.TrimSpace(body.Username), len(permissions), enabledWord(enabled)),
|
||||
Details: map[string]any{
|
||||
"username": strings.TrimSpace(body.Username),
|
||||
"permissions": permissions,
|
||||
"enabled": enabled,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.createAdminConsoleUser(r.Context(), body.Username, body.Password, permissions, enabled)
|
||||
if err != nil {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: "create-admin-operator"}, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, admin.CommandResult{
|
||||
CommandID: meta.CommandID,
|
||||
Action: "create-admin-operator",
|
||||
Status: "ok",
|
||||
Message: fmt.Sprintf("Created operator %q.", user.Username),
|
||||
Details: map[string]any{"id": user.ID, "username": user.Username, "permissions": user.Permissions, "enabled": user.Enabled},
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateAdminUserAPI changes rights and/or enabled state, dry run first.
|
||||
func (s *server) handleUpdateAdminUserAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body adminUserActionRequest
|
||||
if !s.decodeAdminUserAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "admin-operator-access")
|
||||
const action = "set-admin-operator-access"
|
||||
enabled := body.Enabled == nil || *body.Enabled
|
||||
permissions := normalisePermissions(body.Permissions)
|
||||
|
||||
if body.ID <= 0 {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, errAdminUserNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.guardManagerRemoval(r.Context(), body.ID, permissions, enabled); err != nil {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, err)
|
||||
return
|
||||
}
|
||||
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, admin.CommandResult{
|
||||
CommandID: meta.CommandID,
|
||||
Action: action,
|
||||
Status: "ok",
|
||||
DryRun: true,
|
||||
Message: fmt.Sprintf("Would set operator #%d to %d permission(s), %s. Takes effect on their next request.",
|
||||
body.ID, len(permissions), enabledWord(enabled)),
|
||||
Details: map[string]any{"id": body.ID, "permissions": permissions, "enabled": enabled},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.updateAdminConsoleUser(r.Context(), body.ID, permissions, enabled)
|
||||
if err != nil {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, admin.CommandResult{
|
||||
CommandID: meta.CommandID,
|
||||
Action: action,
|
||||
Status: "ok",
|
||||
Message: fmt.Sprintf("Updated %q. The new access applies from their next request.", user.Username),
|
||||
Details: map[string]any{"id": user.ID, "username": user.Username, "permissions": user.Permissions, "enabled": user.Enabled},
|
||||
})
|
||||
}
|
||||
|
||||
// handleSetAdminUserPasswordAPI resets a password, dry run first.
|
||||
func (s *server) handleSetAdminUserPasswordAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body adminUserActionRequest
|
||||
if !s.decodeAdminUserAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "admin-operator-password")
|
||||
const action = "set-admin-operator-password"
|
||||
|
||||
if body.ID <= 0 {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, errAdminUserNotFound)
|
||||
return
|
||||
}
|
||||
if err := validateAdminPassword(body.Password); err != nil {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, err)
|
||||
return
|
||||
}
|
||||
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, admin.CommandResult{
|
||||
CommandID: meta.CommandID,
|
||||
Action: action,
|
||||
Status: "ok",
|
||||
DryRun: true,
|
||||
Message: fmt.Sprintf("Would set a new password for operator #%d. Their existing sessions would be signed out.", body.ID),
|
||||
// The password itself is never echoed, not even back to the
|
||||
// operator who just typed it.
|
||||
Details: map[string]any{"id": body.ID},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.setAdminConsoleUserPassword(r.Context(), body.ID, body.Password); err != nil {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, admin.CommandResult{
|
||||
CommandID: meta.CommandID,
|
||||
Action: action,
|
||||
Status: "ok",
|
||||
Message: fmt.Sprintf("Password changed for operator #%d. Their existing sessions are signed out.", body.ID),
|
||||
Details: map[string]any{"id": body.ID},
|
||||
})
|
||||
}
|
||||
|
||||
// decodeAdminUserAction shares the store check, body decode and reason
|
||||
// requirement across the three mutations.
|
||||
func (s *server) decodeAdminUserAction(w http.ResponseWriter, r *http.Request, body *adminUserActionRequest) bool {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return false
|
||||
}
|
||||
if err := decodeJSON(r, body); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(body.Reason) == "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "a reason is required")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func enabledWord(enabled bool) string {
|
||||
if enabled {
|
||||
return "enabled"
|
||||
}
|
||||
return "disabled"
|
||||
}
|
||||
|
||||
// guardManagerRemoval refuses an edit that would leave nobody able to manage
|
||||
// operators. Counted over the other accounts, so demoting or disabling the
|
||||
// only remaining manager is what trips it.
|
||||
func (s *server) guardManagerRemoval(ctx context.Context, id int64, permissions []string, enabled bool) error {
|
||||
stillManages := enabled && newPanelPermissions(permissions).Has(permissionAdminsManage)
|
||||
if stillManages {
|
||||
return nil
|
||||
}
|
||||
others, err := s.read.CountEnabledAdminConsoleUsersWith(ctx, permissionAdminsManage, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if others == 0 {
|
||||
return errLastManagerStanding
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// guardManagerRemoval is the only thing between an operator and a console
|
||||
// nobody can administer, and it decides from a COUNT over admin_console_users
|
||||
// whose predicate treats '*' as holding every right. Neither that wildcard
|
||||
// matching nor the enabled filter can be proven anywhere but against the real
|
||||
// table, so this is an integration test, gated on TELESRV_TEST_POSTGRES_DSN
|
||||
// like the rest of the package.
|
||||
//
|
||||
// The count spans every enabled manager except the row being edited, so unlike
|
||||
// the other integration tests here this one cannot keep to its own fixtures
|
||||
// with a unique suffix -- a manager left behind by an earlier run would make
|
||||
// the "nobody else" cases silently pass for the wrong reason. It empties
|
||||
// admin_console_users instead; verificationReadStore refuses a DSN whose
|
||||
// database name does not contain "test", which is what makes that safe, and no
|
||||
// other test in the repo touches this table.
|
||||
func TestGuardManagerRemovalIntegration(t *testing.T) {
|
||||
store, pool := verificationReadStore(t)
|
||||
srv := &server{read: store}
|
||||
ctx := context.Background()
|
||||
|
||||
resetTable := func() {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(ctx, `TRUNCATE admin_console_users RESTART IDENTITY`); err != nil {
|
||||
t.Fatalf("truncate admin_console_users: %v", err)
|
||||
}
|
||||
}
|
||||
resetTable()
|
||||
t.Cleanup(resetTable)
|
||||
|
||||
unique := time.Now().UnixNano() & 0x7fffffff
|
||||
insertOperator := func(permissions []string, enabled bool) int64 {
|
||||
t.Helper()
|
||||
unique++
|
||||
var id int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO admin_console_users (username, password_hash, permissions, enabled)
|
||||
VALUES ($1, 'not-a-real-hash', $2, $3)
|
||||
RETURNING id`, "guardop"+strconv.FormatInt(unique, 10), permissions, enabled).Scan(&id); err != nil {
|
||||
t.Fatalf("insert operator: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
t.Run("sole wildcard holder cannot drop the wildcard", func(t *testing.T) {
|
||||
resetTable()
|
||||
id := insertOperator([]string{permissionAll}, true)
|
||||
err := srv.guardManagerRemoval(ctx, id, []string{permissionAccountsRead}, true)
|
||||
if !errors.Is(err, errLastManagerStanding) {
|
||||
t.Fatalf("err = %v, want errLastManagerStanding", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sole manager cannot disable itself", func(t *testing.T) {
|
||||
resetTable()
|
||||
id := insertOperator([]string{permissionAll}, true)
|
||||
err := srv.guardManagerRemoval(ctx, id, []string{permissionAll}, false)
|
||||
if !errors.Is(err, errLastManagerStanding) {
|
||||
t.Fatalf("err = %v, want errLastManagerStanding", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Narrowing the wildcard down to the managing right itself is the supported
|
||||
// way out of full access, so it must not trip the guard.
|
||||
t.Run("sole manager may trade the wildcard for admins.manage", func(t *testing.T) {
|
||||
resetTable()
|
||||
id := insertOperator([]string{permissionAll}, true)
|
||||
if err := srv.guardManagerRemoval(ctx, id, []string{permissionAdminsManage}, true); err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
|
||||
// The case the SQL's '*' arm exists for: the remaining manager holds the
|
||||
// wildcard rather than a literal admins.manage, and must still be counted.
|
||||
t.Run("another enabled wildcard holder counts as a manager", func(t *testing.T) {
|
||||
resetTable()
|
||||
id := insertOperator([]string{permissionAll}, true)
|
||||
insertOperator([]string{permissionAll}, true)
|
||||
if err := srv.guardManagerRemoval(ctx, id, []string{permissionAccountsRead}, true); err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a disabled second manager does not count", func(t *testing.T) {
|
||||
resetTable()
|
||||
id := insertOperator([]string{permissionAll}, true)
|
||||
insertOperator([]string{permissionAdminsManage}, false)
|
||||
err := srv.guardManagerRemoval(ctx, id, []string{permissionAccountsRead}, true)
|
||||
if !errors.Is(err, errLastManagerStanding) {
|
||||
t.Fatalf("err = %v, want errLastManagerStanding", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
package main
|
||||
|
||||
import "runtime/debug"
|
||||
|
||||
// gitCommit/buildTime can be set via -ldflags "-X main.gitCommit=... -X
|
||||
// main.buildTime=...", mirroring cmd/telesrv/buildinfo.go -- but in
|
||||
// practice neither procctl's goBuild (used by the admin panel's own
|
||||
// Restart/Update) nor a plain `go build` sets them, so this normally falls
|
||||
// back to Go's automatic VCS stamping (debug.ReadBuildInfo's vcs.revision),
|
||||
// which needs nothing extra to work from a git checkout.
|
||||
var (
|
||||
gitCommit = ""
|
||||
buildTime = ""
|
||||
)
|
||||
|
||||
type buildMetadata struct {
|
||||
Commit string
|
||||
Dirty bool
|
||||
BuildTime string
|
||||
}
|
||||
|
||||
// shortCommit is what the sidebar footer shows next to "Version: O7" -- the
|
||||
// full hash is one click away in git log, the footer just needs enough to
|
||||
// tell two builds apart at a glance.
|
||||
func (m buildMetadata) shortCommit() string {
|
||||
if len(m.Commit) > 7 {
|
||||
return m.Commit[:7]
|
||||
}
|
||||
return m.Commit
|
||||
}
|
||||
|
||||
func currentBuildMetadata() buildMetadata {
|
||||
meta := buildMetadata{Commit: gitCommit, BuildTime: buildTime}
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
settings := map[string]string{}
|
||||
for _, setting := range info.Settings {
|
||||
settings[setting.Key] = setting.Value
|
||||
}
|
||||
if meta.Commit == "" {
|
||||
meta.Commit = settings["vcs.revision"]
|
||||
}
|
||||
meta.Dirty = settings["vcs.modified"] == "true"
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
|
@ -7,11 +7,9 @@ import (
|
|||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
|
@ -29,23 +27,6 @@ const hostStatsPollInterval = 5 * time.Second
|
|||
|
||||
const defaultAdminAPIAddr = "127.0.0.1:2599"
|
||||
|
||||
// bootID is a random value generated once per process start, exposed via
|
||||
// GET /api/session -- see that handler's doc comment for why (the
|
||||
// Restart/Update polling flow's way of detecting a genuinely new admin
|
||||
// process, not just a slow-to-respond old one).
|
||||
var bootID = newBootID()
|
||||
|
||||
func newBootID() string {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
// crypto/rand failing is effectively unheard of on any real target
|
||||
// this binary runs on; falling back to the wall clock still gives a
|
||||
// value that changes across restarts, which is all this is for.
|
||||
return fmt.Sprintf("t%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
|
|
@ -66,7 +47,7 @@ func run() error {
|
|||
}
|
||||
defer pool.Close()
|
||||
|
||||
hs := hoststats.NewPoller(cfg.DiskStatsPath)
|
||||
hs := hoststats.NewPoller(cfg.BlobDir)
|
||||
go hs.Run(ctx, hostStatsPollInterval)
|
||||
|
||||
srv, err := newServer(cfg, newReadStore(pool), hs)
|
||||
|
|
@ -99,10 +80,10 @@ type uiConfig struct {
|
|||
Password string
|
||||
Token string
|
||||
SessionKey []byte
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
|
|
@ -114,41 +95,6 @@ type uiConfig struct {
|
|||
// permissions, and the session/login response tells the frontend to hide
|
||||
// the "Third-party marks" nav entry and its routes.
|
||||
HideThirdPartyVerification bool
|
||||
// IdentityDir mirrors config.IdentityDir -- must point at the same
|
||||
// directory owpengram-server reads, so an identity edit here is visible
|
||||
// over /owpengram/server-info immediately (see internal/identity).
|
||||
IdentityDir string
|
||||
// WelcomeMessagePhoneDefault/WelcomeMessageEmailDefault mirror
|
||||
// config.WelcomeMessage{Phone,Email}Template -- the env-var-resolved
|
||||
// fallback text (TELESRV_WELCOME_MESSAGE_*_TEMPLATE, itself defaulting
|
||||
// to the compiled-in copy) the running owpengram-server process falls
|
||||
// back to whenever the identity panel override is unset. Surfaced as
|
||||
// "the effective default" in the Server Settings login-notifications
|
||||
// panel, assuming both binaries share the same .env.
|
||||
WelcomeMessagePhoneDefault string
|
||||
WelcomeMessageEmailDefault string
|
||||
// LoginCodeMessageDefault mirrors config.LoginCodeMessageTemplate -- the
|
||||
// env-var-resolved fallback text (TELESRV_LOGIN_CODE_MESSAGE_TEMPLATE,
|
||||
// itself defaulting to the compiled-in copy) the running owpengram-server
|
||||
// process falls back to whenever the identity panel override is unset.
|
||||
// Same "effective default" contract as WelcomeMessage{Phone,Email}Default.
|
||||
LoginCodeMessageDefault string
|
||||
// RepoRoot is where Server Settings' Restart/Update/.env-editing (see
|
||||
// internal/procctl) operate: bin/, logs/, .env, .env.example and
|
||||
// .server_panel.json are all expected directly under it, exactly as
|
||||
// tui-panel/server-panel.py expects. Defaults to the process's current
|
||||
// working directory, which is correct whenever this binary is launched
|
||||
// from (or by something that cd'd into) the repo root -- true both for a
|
||||
// manual run and for how the TUI itself launches it.
|
||||
RepoRoot string
|
||||
// AdvertiseHost/ServerPort mirror config.AdvertiseIP and the port half
|
||||
// of config.ListenAddr -- what the "Add Server" sidebar button needs to
|
||||
// build an owpg://addserver link for this exact server (host+port only,
|
||||
// see handleAddServerLinkAPI's doc comment for why nothing else belongs
|
||||
// in that link), assuming both binaries share the same .env (same
|
||||
// convention as the WelcomeMessage/LoginCode defaults above).
|
||||
AdvertiseHost string
|
||||
ServerPort int
|
||||
}
|
||||
|
||||
// loadConfig 通过 internal/config.Load() 加载 .env 配置文件与环境变量,
|
||||
|
|
@ -175,20 +121,6 @@ func loadConfig() (uiConfig, error) {
|
|||
}
|
||||
sum := sha256.Sum256([]byte(appCfg.AdminSessionKey))
|
||||
|
||||
repoRoot, err := os.Getwd()
|
||||
if err != nil {
|
||||
return uiConfig{}, fmt.Errorf("resolve repo root: %w", err)
|
||||
}
|
||||
|
||||
_, serverPortStr, err := net.SplitHostPort(appCfg.ListenAddr)
|
||||
if err != nil {
|
||||
return uiConfig{}, fmt.Errorf("parse TELESRV_LISTEN %q: %w", appCfg.ListenAddr, err)
|
||||
}
|
||||
serverPort, err := strconv.Atoi(serverPortStr)
|
||||
if err != nil {
|
||||
return uiConfig{}, fmt.Errorf("parse TELESRV_LISTEN port %q: %w", serverPortStr, err)
|
||||
}
|
||||
|
||||
return uiConfig{
|
||||
Addr: appCfg.AdminUIAddr,
|
||||
PostgresDSN: appCfg.PostgresDSN,
|
||||
|
|
@ -197,26 +129,12 @@ func loadConfig() (uiConfig, error) {
|
|||
Password: appCfg.AdminUIPassword,
|
||||
Token: appCfg.AdminUIToken,
|
||||
SessionKey: sum[:],
|
||||
DiskStatsPath: dashboardDiskPath(appCfg),
|
||||
Permissions: appCfg.AdminUIPermissions,
|
||||
HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
|
||||
IdentityDir: appCfg.IdentityDir,
|
||||
WelcomeMessagePhoneDefault: appCfg.WelcomeMessagePhoneTemplate,
|
||||
WelcomeMessageEmailDefault: appCfg.WelcomeMessageEmailTemplate,
|
||||
LoginCodeMessageDefault: appCfg.LoginCodeMessageTemplate,
|
||||
RepoRoot: repoRoot,
|
||||
AdvertiseHost: appCfg.AdvertiseIP,
|
||||
ServerPort: serverPort,
|
||||
BlobDir: appCfg.BlobDir,
|
||||
}, 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 == "" {
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// The login screen shows which server it belongs to, so the two fields it needs
|
||||
// are served without a session.
|
||||
//
|
||||
// This discloses nothing new. owpengram-server already publishes the same name
|
||||
// and icon to every client that asks, over its own open endpoints
|
||||
// (/owpengram/server-info and /owpengram/server-icon) -- that is how a client
|
||||
// fills in the "Add Server" form and how the panel's own RefreshServersInfo
|
||||
// works. Branding is public by design; the point of a server name is to be
|
||||
// read before you are anybody.
|
||||
//
|
||||
// It stays deliberately narrow all the same: name and whether an icon exists,
|
||||
// and nothing else off identity.Info, which also carries the description and
|
||||
// the welcome/login-code message templates. Those are operator-facing settings
|
||||
// and stay behind server.manage.
|
||||
|
||||
type publicBrandingResponse struct {
|
||||
Name string `json:"name"`
|
||||
HasIcon bool `json:"has_icon"`
|
||||
}
|
||||
|
||||
func (s *server) handlePublicBrandingAPI(w http.ResponseWriter, _ *http.Request) {
|
||||
out := publicBrandingResponse{}
|
||||
if s.identity != nil {
|
||||
if info, err := s.identity.Get(); err == nil {
|
||||
out.Name = info.Name
|
||||
}
|
||||
_, _, ok := s.identity.Icon()
|
||||
out.HasIcon = ok
|
||||
}
|
||||
// A server that has not been named yet answers with an empty name rather
|
||||
// than an error: the login page falls back to its own branding, and a
|
||||
// failed request there would only produce a console error for nothing.
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handlePublicIconAPI serves the same bytes as handleServerIconAPI, without the
|
||||
// session. Same file, same rationale as above.
|
||||
func (s *server) handlePublicIconAPI(w http.ResponseWriter, _ *http.Request) {
|
||||
if s.identity == nil {
|
||||
http.NotFound(w, nil)
|
||||
return
|
||||
}
|
||||
data, ext, ok := s.identity.Icon()
|
||||
if !ok {
|
||||
http.Error(w, "no icon configured", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
contentType := map[string]string{
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".webp": "image/webp", ".gif": "image/gif",
|
||||
}[ext]
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ import (
|
|||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -387,7 +386,6 @@ LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.ed
|
|||
LEFT JOIN auth a ON a.user_id = u.id
|
||||
LEFT JOIN account_passwords ap ON ap.user_id = u.id
|
||||
WHERE NOT u.is_bot
|
||||
AND NOT (u.id = ANY($8::bigint[]))
|
||||
AND (
|
||||
u.id = $1
|
||||
OR u.phone LIKE $2
|
||||
|
|
@ -398,7 +396,7 @@ WHERE NOT u.is_bot
|
|||
)
|
||||
AND ($5::bigint = 0 OR (COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), u.id) < (to_timestamp(($5::double precision) / 1000000.0), $6::bigint))
|
||||
ORDER BY COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz) DESC, u.id DESC
|
||||
LIMIT $7`, id, phonePrefix, substring, term, beforeActiveUS, beforeID, limit+1, domain.SystemUserIDs())
|
||||
LIMIT $7`, id, phonePrefix, substring, term, beforeActiveUS, beforeID, limit+1)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("search accounts: %w", err)
|
||||
}
|
||||
|
|
@ -510,77 +508,44 @@ type DashboardCounts struct {
|
|||
PendingVerifications int64
|
||||
}
|
||||
|
||||
// DashboardCounts gathers every headline number on the admin overview.
|
||||
//
|
||||
// The queries are independent, so they run concurrently: this used to be eight
|
||||
// round trips in series and the page waited for their sum. errgroup cancels the
|
||||
// rest as soon as one fails, and each goroutine writes to its own field of
|
||||
// `out`, so no locking is needed.
|
||||
func (s *readStore) DashboardCounts(ctx context.Context) (DashboardCounts, error) {
|
||||
var out DashboardCounts
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
v, err := s.CountAccounts(gctx)
|
||||
out.Users = v
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
v, err := s.CountOnlineAccounts(gctx)
|
||||
out.OnlineUsers = v
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
if err := s.pool.QueryRow(gctx, `
|
||||
var err error
|
||||
if out.Users, err = s.CountAccounts(ctx); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.OnlineUsers, err = s.CountOnlineAccounts(ctx); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM users WHERE is_bot AND deleted_at IS NULL`).Scan(&out.Bots); err != nil {
|
||||
return fmt.Errorf("count bots: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
if err := s.pool.QueryRow(gctx, `
|
||||
return out, fmt.Errorf("count bots: %w", err)
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FILTER (WHERE broadcast), count(*) FILTER (WHERE megagroup)
|
||||
FROM channels WHERE NOT deleted AND NOT monoforum`).Scan(&out.BroadcastChannels, &out.Supergroups); err != nil {
|
||||
return fmt.Errorf("count channels: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
if err := s.pool.QueryRow(gctx, `
|
||||
return out, fmt.Errorf("count channels: %w", err)
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FILTER (WHERE set_kind = 'stickers'), count(*) FILTER (WHERE set_kind = 'emoji')
|
||||
FROM sticker_sets WHERE deleted = false`).Scan(&out.StickerSets, &out.EmojiSets); err != nil {
|
||||
return fmt.Errorf("count sticker sets: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
// There's no global GIF catalog -- a GIF is just a document a user saved
|
||||
// to their personal collection (messages.saveGif). This counts distinct
|
||||
// documents saved by anyone, the closest thing to "how many GIFs does
|
||||
// this server know about."
|
||||
if err := s.pool.QueryRow(gctx, `
|
||||
return out, fmt.Errorf("count sticker sets: %w", err)
|
||||
}
|
||||
// There's no global GIF catalog -- a GIF is just a document a user saved to
|
||||
// their personal collection (messages.saveGif). This counts distinct
|
||||
// documents saved by anyone, the closest thing to "how many GIFs does this
|
||||
// server know about."
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(DISTINCT document_id) FROM user_sticker_collections WHERE kind = 'gif'`).Scan(&out.Gifs); err != nil {
|
||||
return fmt.Errorf("count gifs: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
if err := s.pool.QueryRow(gctx, `
|
||||
return out, fmt.Errorf("count gifs: %w", err)
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM moderation_cases WHERE status NOT IN ('resolved', 'dismissed')`).Scan(&out.PendingReports); err != nil {
|
||||
return fmt.Errorf("count pending moderation cases: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
if err := s.pool.QueryRow(gctx, `
|
||||
return out, fmt.Errorf("count pending moderation cases: %w", err)
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM verification_applications WHERE status IN ('submitted', 'in_review')`).Scan(&out.PendingVerifications); err != nil {
|
||||
return fmt.Errorf("count pending verification applications: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return DashboardCounts{}, err
|
||||
return out, fmt.Errorf("count pending verification applications: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -885,10 +850,9 @@ LEFT JOIN account_restrictions r ON r.user_id = u.id
|
|||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable
|
||||
LEFT JOIN account_passwords ap ON ap.user_id = u.id
|
||||
WHERE NOT u.is_bot
|
||||
AND NOT (u.id = ANY($4::bigint[]))
|
||||
AND ($1::bigint = 0 OR (COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
|
||||
ORDER BY COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz) DESC, u.id DESC
|
||||
LIMIT $3`, beforeActiveUS, beforeID, limit+1, domain.SystemUserIDs())
|
||||
LIMIT $3`, beforeActiveUS, beforeID, limit+1)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list accounts: %w", err)
|
||||
}
|
||||
|
|
@ -993,31 +957,27 @@ ORDER BY g.last_active_at DESC, g.device_model, g.system_version, g.platform, g.
|
|||
return groups, hasMore, nil
|
||||
}
|
||||
|
||||
// BroadcastRow is one system-broadcast campaign. SentCount/FailedCount/
|
||||
// MaterializedCount are maintained incrementally by the delivery worker as
|
||||
// it closes out each recipient row (see internal/app/broadcast); for an
|
||||
// "all"-mode campaign still enumerating, TargetCount grows until
|
||||
// EnumerationDone.
|
||||
// BroadcastRow is one system-broadcast campaign, with sent/failed counts
|
||||
// derived live from broadcast_recipients (never stored, so they can't drift).
|
||||
type BroadcastRow struct {
|
||||
ID int64
|
||||
Message string
|
||||
TargetMode string
|
||||
TargetCount int64
|
||||
MaterializedCount int64
|
||||
SentCount int64
|
||||
FailedCount int64
|
||||
EnumerationDone bool
|
||||
CreatedBy string
|
||||
CreatedAt time.Time
|
||||
ID int64
|
||||
Message string
|
||||
TargetMode string
|
||||
TotalCount int
|
||||
SentCount int
|
||||
FailedCount int
|
||||
CreatedBy string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const broadcastRowColumns = `
|
||||
b.id, b.message, b.target_mode, b.target_count, b.materialized_count,
|
||||
b.sent_count, b.failed_count, b.enumeration_done, b.created_by, b.created_at`
|
||||
b.id, b.message, b.target_mode, b.total_count, b.created_by, b.created_at,
|
||||
count(*) FILTER (WHERE r.status = 'sent')::int AS sent_count,
|
||||
count(*) FILTER (WHERE r.status = 'failed')::int AS failed_count`
|
||||
|
||||
func scanBroadcastRow(row interface{ Scan(...any) error }, item *BroadcastRow) error {
|
||||
return row.Scan(&item.ID, &item.Message, &item.TargetMode, &item.TargetCount, &item.MaterializedCount,
|
||||
&item.SentCount, &item.FailedCount, &item.EnumerationDone, &item.CreatedBy, &item.CreatedAt)
|
||||
return row.Scan(&item.ID, &item.Message, &item.TargetMode, &item.TotalCount, &item.CreatedBy, &item.CreatedAt,
|
||||
&item.SentCount, &item.FailedCount)
|
||||
}
|
||||
|
||||
// ListBroadcasts pages campaigns newest-first.
|
||||
|
|
@ -1028,7 +988,9 @@ func (s *readStore) ListBroadcasts(ctx context.Context, beforeID int64, limit in
|
|||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+broadcastRowColumns+`
|
||||
FROM broadcasts b
|
||||
LEFT JOIN broadcast_recipients r ON r.broadcast_id = b.id
|
||||
WHERE $1::bigint = 0 OR b.id < $1
|
||||
GROUP BY b.id
|
||||
ORDER BY b.id DESC
|
||||
LIMIT $2`, beforeID, limit+1)
|
||||
if err != nil {
|
||||
|
|
@ -2576,181 +2538,62 @@ const (
|
|||
)
|
||||
|
||||
// perOwnerMediaSizeSQL is shared between StorageStats and
|
||||
// ListAccountStorageUsage: attributes each document/photo's REAL remaining
|
||||
// bytes (the sum of every file_blobs row it still owns -- main body plus
|
||||
// thumbnail/rendition variants) to its owner, one row per document/photo so
|
||||
// an outer COUNT(*)/SUM(size) aggregate still gets an accurate file count
|
||||
// alongside the byte total. This used to read documents.size / the largest
|
||||
// photo rendition size directly -- a static value on the metadata row that
|
||||
// survives a hard-retention purge unchanged, so it kept counting bytes for
|
||||
// files whose blobs were long gone. Joining through file_blobs instead means
|
||||
// a purged item correctly contributes 0: there is nothing left to attribute.
|
||||
// ListAccountStorageUsage: each document row's size is its stored column;
|
||||
// each photo row has no single size column (JSONB sizes holds one entry per
|
||||
// rendition), so its "attributed size" is the largest rendition -- the
|
||||
// dominant cost, thumbnails are comparatively tiny. This is an
|
||||
// approximation (not the exact sum of every rendition's blob bytes, which
|
||||
// would require joining file_blobs by location_key prefix) chosen for admin
|
||||
// visibility, not billing precision.
|
||||
const perOwnerMediaSizeSQL = `
|
||||
SELECT d.owner_user_id, COALESCE((
|
||||
SELECT SUM(fb.size) FROM file_blobs fb
|
||||
WHERE fb.location_key = 'doc:' || d.id::text
|
||||
OR fb.location_key LIKE 'doc:' || d.id::text || ':%'
|
||||
), 0) AS size
|
||||
FROM documents d
|
||||
SELECT owner_user_id, size FROM documents
|
||||
UNION ALL
|
||||
SELECT p.owner_user_id, COALESCE((
|
||||
SELECT SUM(fb.size) FROM file_blobs fb
|
||||
WHERE fb.location_key = 'photo:' || p.id::text
|
||||
OR fb.location_key LIKE 'photo:' || p.id::text || ':%'
|
||||
SELECT MAX((elem->>'size')::bigint) FROM jsonb_array_elements(p.sizes) elem
|
||||
), 0) AS size
|
||||
FROM photos p
|
||||
`
|
||||
|
||||
// StorageStatsRow is the admin panel's storage overview: physical bytes
|
||||
// (from file_blobs, backend-dedup-aware -- what's actually consuming disk
|
||||
// or S3, deduplicated exactly once across the whole system) versus logical
|
||||
// bytes (the SAME real, still-existing file_blobs bytes as physical, just
|
||||
// summed per-owner via perOwnerMediaSizeSQL without deduplicating content
|
||||
// shared across accounts/documents -- so logical can legitimately be higher
|
||||
// than physical when the same blob is attributed to more than one
|
||||
// document/photo, but a purged file with no file_blobs rows left correctly
|
||||
// contributes 0 to both, never a stale non-zero "ghost" size).
|
||||
//
|
||||
// Every field here deliberately EXCLUDES system/bundled content (owner_user_id
|
||||
// = 0 on the documents/photos row -- the built-in sticker packs, emoji sets,
|
||||
// default wallpapers, GIF catalog, and system-bot avatars this server seeds
|
||||
// at every boot; see internal/app/files's Seed* functions). None of that is
|
||||
// something an operator manages through storage retention/purge -- it isn't
|
||||
// one of the Photo/Video/GIF/Music/Voice/File/Avatar categories those
|
||||
// controls target, it's permanent server furniture -- so counting it here
|
||||
// alongside real user uploads made every number on this page mean "user
|
||||
// storage plus an unpredictable pile of bundled assets" instead of just
|
||||
// answering "how much space are my users actually using". SystemBytes below
|
||||
// is the one exception: it reports that excluded total separately, purely
|
||||
// for an operator's own curiosity/disk-accounting, never folded into the
|
||||
// other totals.
|
||||
// or S3) versus logical bytes (sum of the same approximate per-row
|
||||
// attribution the per-account breakdown uses, which can legitimately be
|
||||
// higher than physical when identical content is shared by more than one
|
||||
// document/photo).
|
||||
type StorageStatsRow struct {
|
||||
PhysicalBytes int64 `json:"PhysicalBytes,string"`
|
||||
LogicalBytes int64 `json:"LogicalBytes,string"`
|
||||
// SystemBytes is the physical size of excluded system/bundled content
|
||||
// (owner_user_id = 0) -- shown separately so the gap between this and
|
||||
// what `docker exec ... mc du` or the MinIO console reports isn't a
|
||||
// mystery, but never added into PhysicalBytes/LogicalBytes/DocumentCount/
|
||||
// PhotoCount/AccountCount above.
|
||||
SystemBytes int64 `json:"SystemBytes,string"`
|
||||
// DocumentCount, PhotoCount and AccountCount all count only items that
|
||||
// still own real file_blobs bytes -- documents/photos rows are
|
||||
// deliberately kept forever after a hard-retention purge (so a message
|
||||
// can still render "here was a file"), so counting rows instead of live
|
||||
// bytes would keep growing even as the actual content becomes physically
|
||||
// empty, diverging further and further from PhysicalBytes above.
|
||||
DocumentCount int64 `json:"DocumentCount,string"`
|
||||
PhotoCount int64 `json:"PhotoCount,string"`
|
||||
AccountCount int64 `json:"AccountCount,string"`
|
||||
BackendKind string
|
||||
PhysicalBytes int64 `json:"PhysicalBytes,string"`
|
||||
LogicalBytes int64 `json:"LogicalBytes,string"`
|
||||
UnattributedBytes int64 `json:"UnattributedBytes,string"`
|
||||
DocumentCount int64 `json:"DocumentCount,string"`
|
||||
PhotoCount int64 `json:"PhotoCount,string"`
|
||||
AccountCount int64 `json:"AccountCount,string"`
|
||||
BackendKind string
|
||||
}
|
||||
|
||||
// StorageStats returns the admin panel's storage overview.
|
||||
// StorageStats runs six aggregates over file_blobs/documents/photos. They are
|
||||
// independent and each is expensive, so they run concurrently rather than
|
||||
// summing their latencies -- see file_blobs_location_key_pattern_idx for why
|
||||
// they were slow in the first place.
|
||||
func (s *readStore) StorageStats(ctx context.Context) (StorageStatsRow, error) {
|
||||
var stats StorageStatsRow
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
|
||||
// Physical usage dedups by (backend, object_key) like the unfiltered
|
||||
// version used to, but only counts an object if at least one real user's
|
||||
// (owner_user_id <> 0) document/photo still references it -- content
|
||||
// shared between a system asset and a real upload (content-addressed
|
||||
// storage, so only possible via a byte-for-byte coincidental duplicate)
|
||||
// still counts, since a real user genuinely has that data stored.
|
||||
g.Go(func() error {
|
||||
// Reads the owner out of the location_key ("doc:<id>", "doc:<id>:<thumb>",
|
||||
// "photo:<id>:<size>") and looks it up by primary key, instead of asking
|
||||
// "is there any document whose id, glued into a string, equals this key".
|
||||
//
|
||||
// That original phrasing was the one query file_blobs_location_key_pattern_idx
|
||||
// could not help: the pattern is built from d.id and matched against
|
||||
// fb.location_key, so no index on location_key applies and every blob had
|
||||
// to scan documents (then photos) end to end. This direction is a plain
|
||||
// PK probe per blob.
|
||||
//
|
||||
// The CASE keeps the cast total -- a key whose second field isn't numeric
|
||||
// yields NULL rather than raising, and NULL matches no id, exactly like
|
||||
// the string form matched no row. Keys of other kinds ("enc:<id>") are
|
||||
// excluded by the kind check, as before.
|
||||
if err := s.pool.QueryRow(gctx, `
|
||||
SELECT COALESCE(SUM(size), 0)::bigint FROM (
|
||||
SELECT DISTINCT ON (fb.backend, fb.object_key) fb.backend, fb.object_key, fb.size
|
||||
FROM file_blobs fb
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT split_part(fb.location_key, ':', 1) AS kind,
|
||||
CASE WHEN split_part(fb.location_key, ':', 2) ~ '^[0-9]+$'
|
||||
THEN split_part(fb.location_key, ':', 2)::bigint
|
||||
END AS owner_id
|
||||
) k
|
||||
WHERE (k.kind = 'doc' AND EXISTS (
|
||||
SELECT 1 FROM documents d WHERE d.id = k.owner_id AND d.owner_user_id <> 0
|
||||
)) OR (k.kind = 'photo' AND EXISTS (
|
||||
SELECT 1 FROM photos p WHERE p.id = k.owner_id AND p.owner_user_id <> 0
|
||||
))
|
||||
) x`).Scan(&stats.PhysicalBytes); err != nil {
|
||||
return fmt.Errorf("sum physical blob bytes: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
if err := s.pool.QueryRow(gctx, `
|
||||
SELECT COALESCE(SUM(size), 0)::bigint FROM (`+perOwnerMediaSizeSQL+`) x WHERE owner_user_id <> 0`).Scan(&stats.LogicalBytes); err != nil {
|
||||
return fmt.Errorf("sum logical media bytes: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
if err := s.pool.QueryRow(gctx, `
|
||||
SELECT COALESCE(SUM(size), 0)::bigint FROM (`+perOwnerMediaSizeSQL+`) x WHERE owner_user_id = 0`).Scan(&stats.SystemBytes); err != nil {
|
||||
return fmt.Errorf("sum system media bytes: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// Documents/Photos/AccountCount all count only items that still own real
|
||||
// file_blobs bytes -- documents/photos rows are deliberately kept forever
|
||||
// after a hard-retention purge (so a message can still render "here was
|
||||
// a file"), so a plain count(*) would keep growing even as everything it
|
||||
// counts becomes physically empty, wildly diverging from PhysicalBytes
|
||||
// above and making the overview page look broken/confusing rather than
|
||||
// informative. owner_user_id <> 0 excludes system/bundled content -- see
|
||||
// StorageStatsRow's doc comment.
|
||||
g.Go(func() error {
|
||||
if err := s.pool.QueryRow(gctx, `
|
||||
SELECT count(*)::bigint FROM documents d WHERE d.owner_user_id <> 0 AND EXISTS (
|
||||
SELECT 1 FROM file_blobs fb
|
||||
WHERE fb.location_key = 'doc:' || d.id::text
|
||||
OR fb.location_key LIKE 'doc:' || d.id::text || ':%'
|
||||
)`).Scan(&stats.DocumentCount); err != nil {
|
||||
return fmt.Errorf("count documents: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
if err := s.pool.QueryRow(gctx, `
|
||||
SELECT count(*)::bigint FROM photos p WHERE p.owner_user_id <> 0 AND EXISTS (
|
||||
SELECT 1 FROM file_blobs fb
|
||||
WHERE fb.location_key = 'photo:' || p.id::text
|
||||
OR fb.location_key LIKE 'photo:' || p.id::text || ':%'
|
||||
)`).Scan(&stats.PhotoCount); err != nil {
|
||||
return fmt.Errorf("count photos: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
if err := s.pool.QueryRow(gctx, `
|
||||
SELECT count(DISTINCT owner_user_id)::bigint FROM (`+perOwnerMediaSizeSQL+`) x WHERE owner_user_id <> 0 AND size > 0`).Scan(&stats.AccountCount); err != nil {
|
||||
return fmt.Errorf("count storage accounts: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return StorageStatsRow{}, err
|
||||
if err := s.pool.QueryRow(ctx, `SELECT COALESCE(SUM(size), 0)::bigint FROM file_blobs`).Scan(&stats.PhysicalBytes); err != nil {
|
||||
return StorageStatsRow{}, fmt.Errorf("sum physical blob bytes: %w", err)
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(SUM(size), 0)::bigint FROM (`+perOwnerMediaSizeSQL+`) x`).Scan(&stats.LogicalBytes); err != nil {
|
||||
return StorageStatsRow{}, fmt.Errorf("sum logical media bytes: %w", err)
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(SUM(size), 0)::bigint FROM (`+perOwnerMediaSizeSQL+`) x WHERE owner_user_id = 0`).Scan(&stats.UnattributedBytes); err != nil {
|
||||
return StorageStatsRow{}, fmt.Errorf("sum unattributed media bytes: %w", err)
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `SELECT count(*)::bigint FROM documents`).Scan(&stats.DocumentCount); err != nil {
|
||||
return StorageStatsRow{}, fmt.Errorf("count documents: %w", err)
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `SELECT count(*)::bigint FROM photos`).Scan(&stats.PhotoCount); err != nil {
|
||||
return StorageStatsRow{}, fmt.Errorf("count photos: %w", err)
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(DISTINCT owner_user_id)::bigint FROM (`+perOwnerMediaSizeSQL+`) x WHERE owner_user_id <> 0`).Scan(&stats.AccountCount); err != nil {
|
||||
return StorageStatsRow{}, fmt.Errorf("count storage accounts: %w", err)
|
||||
}
|
||||
|
||||
stats.BackendKind = strings.ToLower(strings.TrimSpace(os.Getenv("TELESRV_BLOB_BACKEND")))
|
||||
if stats.BackendKind == "" {
|
||||
stats.BackendKind = "s3"
|
||||
|
|
@ -2768,25 +2611,11 @@ type AccountStorageRow struct {
|
|||
FileCount int64 `json:"FileCount,string"`
|
||||
}
|
||||
|
||||
// storageUsageSortColumns whitelists the columns ListAccountStorageUsage's
|
||||
// sortBy may map to -- never interpolate the caller's sort key directly into
|
||||
// SQL, since unlike a plain value it can't go through a query parameter.
|
||||
var storageUsageSortColumns = map[string]string{
|
||||
"bytes": "t.bytes",
|
||||
"files": "t.file_count",
|
||||
"user_id": "t.owner_user_id",
|
||||
"username": "lower(COALESCE(u.username, ''))",
|
||||
"first_name": "lower(COALESCE(u.first_name, ''))",
|
||||
}
|
||||
|
||||
// ListAccountStorageUsage pages the per-account storage breakdown. sortBy
|
||||
// selects one of storageUsageSortColumns (falls back to "bytes" for an
|
||||
// unknown/empty key); sortDesc reverses it. q, if non-empty, filters to
|
||||
// accounts whose id/username/first name match it. Offset-based (not
|
||||
// keyset): storage administration on a self-hosted deployment doesn't need
|
||||
// to support arbitrarily deep pages efficiently the way an infinite-scroll
|
||||
// feed does.
|
||||
func (s *readStore) ListAccountStorageUsage(ctx context.Context, q string, sortBy string, sortDesc bool, offset, limit int) ([]AccountStorageRow, bool, error) {
|
||||
// ListAccountStorageUsage pages the per-account storage breakdown, largest
|
||||
// user first. Offset-based (not keyset): storage administration on a
|
||||
// self-hosted deployment doesn't need to support arbitrarily deep pages
|
||||
// efficiently the way an infinite-scroll feed does.
|
||||
func (s *readStore) ListAccountStorageUsage(ctx context.Context, offset, limit int) ([]AccountStorageRow, bool, error) {
|
||||
if limit <= 0 {
|
||||
limit = storageUsageListDefaultLimit
|
||||
}
|
||||
|
|
@ -2796,51 +2625,19 @@ func (s *readStore) ListAccountStorageUsage(ctx context.Context, q string, sortB
|
|||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
column, ok := storageUsageSortColumns[sortBy]
|
||||
if !ok {
|
||||
column = storageUsageSortColumns["bytes"]
|
||||
}
|
||||
direction := "ASC"
|
||||
if sortDesc {
|
||||
direction = "DESC"
|
||||
}
|
||||
q = strings.TrimSpace(q)
|
||||
var whereClause string
|
||||
args := []any{}
|
||||
argN := 1
|
||||
if q != "" {
|
||||
id := int64(-1)
|
||||
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
|
||||
id = n
|
||||
}
|
||||
whereClause = fmt.Sprintf(`WHERE t.owner_user_id = $%d
|
||||
OR lower(COALESCE(u.username, '')) LIKE $%d
|
||||
OR lower(COALESCE(u.first_name, '')) LIKE $%d`, argN, argN+1, argN+2)
|
||||
args = append(args, id, "%"+strings.ToLower(q)+"%", "%"+strings.ToLower(q)+"%")
|
||||
argN += 3
|
||||
}
|
||||
offsetArg := argN
|
||||
limitArg := argN + 1
|
||||
args = append(args, offset, limit+1)
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH totals AS (
|
||||
-- size > 0 excludes documents/photos whose file_blobs bytes have already
|
||||
-- been purged -- their row is kept forever (see perOwnerMediaSizeSQL's
|
||||
-- doc comment) so counting every row here would keep FileCount growing
|
||||
-- long after Bytes has settled at (or near) 0, same mismatch this query
|
||||
-- used to have before it was joined through file_blobs at all.
|
||||
SELECT owner_user_id, SUM(size)::bigint AS bytes, COUNT(*)::bigint AS file_count
|
||||
FROM (`+perOwnerMediaSizeSQL+`) x
|
||||
WHERE owner_user_id <> 0 AND size > 0
|
||||
WHERE owner_user_id <> 0
|
||||
GROUP BY owner_user_id
|
||||
)
|
||||
SELECT t.owner_user_id, COALESCE(u.username, ''), COALESCE(u.first_name, ''), t.bytes, t.file_count
|
||||
FROM totals t
|
||||
LEFT JOIN users u ON u.id = t.owner_user_id
|
||||
`+whereClause+`
|
||||
ORDER BY `+column+` `+direction+`, t.owner_user_id
|
||||
OFFSET $`+strconv.Itoa(offsetArg)+`
|
||||
LIMIT $`+strconv.Itoa(limitArg), args...)
|
||||
ORDER BY t.bytes DESC, t.owner_user_id
|
||||
OFFSET $1
|
||||
LIMIT $2`, offset, limit+1)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list account storage usage: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The panel is deny-by-default: an API route must say which right it belongs
|
||||
// to. Registering one with a bare requireAuthAPI would make it answer to every
|
||||
// signed-in operator regardless of what they were granted -- which is how a
|
||||
// scoped account quietly gets the run of the place.
|
||||
//
|
||||
// This reads the source rather than the routing table because that is where the
|
||||
// mistake is made: it fails on the line someone is about to add, and names it.
|
||||
func TestEveryAPIRouteDeclaresAScope(t *testing.T) {
|
||||
// Every /api route must be registered through a wrapper that names a
|
||||
// permission. Whitelisting the wrappers rather than blacklisting the bare
|
||||
// one is what makes this hold for helpers added later: a new wrapper is
|
||||
// unknown here until someone adds it deliberately, so it fails closed.
|
||||
allowed := []string{
|
||||
"s.scopedRoute(",
|
||||
"s.scopedRouteAll(",
|
||||
"s.requirePermission(",
|
||||
"s.requireAdminsManage(",
|
||||
"s.serverManage(",
|
||||
"s.verificationRead(",
|
||||
"s.botVerificationRead(",
|
||||
"s.botVerificationManage(",
|
||||
}
|
||||
// Routes that are reachable before a session exists, each for a stated
|
||||
// reason: /api/login is the way in (it carries its own credential), and the
|
||||
// two branding routes feed the login screen with the server name and icon
|
||||
// that owpengram-server already publishes to every client.
|
||||
exempt := map[string]bool{
|
||||
"POST /api/login": true,
|
||||
"GET /api/public/branding": true,
|
||||
"GET /api/public/icon": true,
|
||||
}
|
||||
|
||||
route := regexp.MustCompile(`mux\.Handle(Func)?\("([A-Z]+ /api/[^"]*)"`)
|
||||
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatalf("read package directory: %v", err)
|
||||
}
|
||||
var offenders []string
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
source, err := os.ReadFile(filepath.Clean(name))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
for _, line := range strings.Split(string(source), "\n") {
|
||||
m := route.FindStringSubmatch(line)
|
||||
if m == nil || exempt[m[2]] {
|
||||
continue
|
||||
}
|
||||
guarded := false
|
||||
for _, wrapper := range allowed {
|
||||
if strings.Contains(line, wrapper) {
|
||||
guarded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !guarded {
|
||||
offenders = append(offenders, name+": "+strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(offenders) > 0 {
|
||||
t.Fatalf("these routes are registered without a permission -- wrap them in s.scopedRoute(permission, ...):\n %s",
|
||||
strings.Join(offenders, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// Every right the account editor offers must be one the routes actually check,
|
||||
// and vice versa -- a name in one list and not the other is either a right
|
||||
// nobody can be granted or a checkbox that grants nothing.
|
||||
func TestAssignablePermissionsMatchWhatRoutesEnforce(t *testing.T) {
|
||||
assignable := make(map[string]bool, len(assignablePermissions()))
|
||||
for _, p := range assignablePermissions() {
|
||||
if p == permissionSessionOnly {
|
||||
t.Fatal("permissionSessionOnly is not a grantable right and must not be offered")
|
||||
}
|
||||
if assignable[p] {
|
||||
t.Fatalf("permission %q is offered twice", p)
|
||||
}
|
||||
assignable[p] = true
|
||||
}
|
||||
if len(assignable) == 0 {
|
||||
t.Fatal("no assignable permissions")
|
||||
}
|
||||
// Spot-check the pairs the sections are built around, so a rename that
|
||||
// misses one half is caught here rather than by an operator who suddenly
|
||||
// cannot open a page.
|
||||
for _, required := range []string{
|
||||
permissionAccountsRead, permissionAccountsManage,
|
||||
permissionChannelsRead, permissionChannelsManage,
|
||||
permissionBotsRead, permissionBotsManage,
|
||||
permissionMessagesRead, permissionMessagesManage,
|
||||
permissionContentRead, permissionContentManage,
|
||||
permissionUsernamesRead, permissionUsernamesManage,
|
||||
permissionStorageRead, permissionStorageManage,
|
||||
permissionBroadcastsRead, permissionBroadcastsSend,
|
||||
permissionModerationReview, permissionDashboardRead,
|
||||
permissionAdminsManage, permissionServerManage,
|
||||
} {
|
||||
if !assignable[required] {
|
||||
t.Errorf("permission %q is enforced somewhere but cannot be granted", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// permissionSessionOnly must stay the empty string: scopedRoute distinguishes
|
||||
// "a session is enough" from a real right by that emptiness, and panelPermissions
|
||||
// drops empty entries, so it can never be smuggled into an account's list.
|
||||
func TestSessionOnlyIsNotGrantable(t *testing.T) {
|
||||
if permissionSessionOnly != "" {
|
||||
t.Fatalf("permissionSessionOnly = %q, want the empty string", permissionSessionOnly)
|
||||
}
|
||||
perms := newPanelPermissions([]string{permissionSessionOnly, permissionAccountsRead})
|
||||
if perms.Has(permissionSessionOnly) {
|
||||
t.Fatal("an empty permission was treated as granted")
|
||||
}
|
||||
if !perms.Has(permissionAccountsRead) {
|
||||
t.Fatal("a real permission alongside it was lost")
|
||||
}
|
||||
}
|
||||
|
|
@ -36,8 +36,6 @@ 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
|
||||
|
|
@ -47,122 +45,8 @@ const (
|
|||
// curates the icon catalogue and strips granted marks.
|
||||
permissionBotVerificationReview = "botverification.review"
|
||||
permissionBotVerificationManage = "botverification.manage"
|
||||
// permissionServerManage gates the whole Server Settings panel: identity
|
||||
// (name/description/icon), .env editing, and Restart/Update -- all of it
|
||||
// meaningfully more sensitive than any domain-data action above (.env
|
||||
// editing exposes every secret the deployment holds; Restart/Update runs
|
||||
// git/go and bounces the live MTProto process), so it is one right, not
|
||||
// split into review/manage like the sections above.
|
||||
permissionServerManage = "server.manage"
|
||||
// permissionAdminsManage gates the operator accounts themselves: creating
|
||||
// them, editing their rights, disabling them, resetting their passwords.
|
||||
//
|
||||
// It is the one right that can grant every other right, so it is never
|
||||
// implied by anything else and is worth handing out to far fewer people
|
||||
// than server.manage. guardManagerRemoval additionally refuses the edit
|
||||
// that would leave nobody holding it.
|
||||
permissionAdminsManage = "admins.manage"
|
||||
|
||||
// Section rights, in read/manage pairs that follow the sidebar. Reading a
|
||||
// section and changing it are separate grants because most of the people
|
||||
// who need to look at this data never need to alter it.
|
||||
permissionAccountsRead = "accounts.read"
|
||||
permissionAccountsManage = "accounts.manage"
|
||||
permissionChannelsRead = "channels.read"
|
||||
permissionChannelsManage = "channels.manage"
|
||||
permissionBotsRead = "bots.read"
|
||||
permissionBotsManage = "bots.manage"
|
||||
permissionMessagesRead = "messages.read"
|
||||
permissionMessagesManage = "messages.manage"
|
||||
permissionModerationReview = "moderation.review"
|
||||
permissionBroadcastsRead = "broadcasts.read"
|
||||
permissionBroadcastsSend = "broadcasts.send"
|
||||
permissionStorageRead = "storage.read"
|
||||
permissionStorageManage = "storage.manage"
|
||||
// Sticker packs, emoji packs and the GIF catalogue: one section as far as
|
||||
// the panel is concerned, so one pair of rights.
|
||||
permissionContentRead = "content.read"
|
||||
permissionContentManage = "content.manage"
|
||||
permissionUsernamesRead = "usernames.read"
|
||||
permissionUsernamesManage = "usernames.manage"
|
||||
permissionDashboardRead = "dashboard.read"
|
||||
|
||||
// permissionSessionOnly marks the handful of routes that need a session but
|
||||
// no right: reading who you are, and signing out. It is not a grantable
|
||||
// name -- scopedRoute treats it as "authenticated is enough" -- so it can
|
||||
// never be typed into an account's permission list by mistake.
|
||||
permissionSessionOnly = ""
|
||||
)
|
||||
|
||||
// assignablePermissions is the vocabulary the operator-accounts screen offers.
|
||||
//
|
||||
// The wildcard is deliberately absent: it is meaningful in
|
||||
// TELESRV_ADMIN_UI_PERMISSIONS for the break-glass login, but handing "*" to a
|
||||
// named account through a UI is how least privilege quietly stops being a
|
||||
// thing. An operator who genuinely needs everything gets every entry ticked,
|
||||
// which at least leaves a legible record of what was granted.
|
||||
func assignablePermissions() []string {
|
||||
return []string{
|
||||
permissionAccountsRead,
|
||||
permissionAccountsManage,
|
||||
permissionChannelsRead,
|
||||
permissionChannelsManage,
|
||||
permissionBotsRead,
|
||||
permissionBotsManage,
|
||||
permissionMessagesRead,
|
||||
permissionMessagesManage,
|
||||
permissionModerationReview,
|
||||
permissionBroadcastsRead,
|
||||
permissionBroadcastsSend,
|
||||
permissionContentRead,
|
||||
permissionContentManage,
|
||||
permissionUsernamesRead,
|
||||
permissionUsernamesManage,
|
||||
permissionStorageRead,
|
||||
permissionStorageManage,
|
||||
permissionDashboardRead,
|
||||
permissionPremiumManage,
|
||||
permissionBotTokenRead,
|
||||
permissionVerificationReview,
|
||||
permissionVerificationRevoke,
|
||||
permissionBotVerificationReview,
|
||||
permissionBotVerificationManage,
|
||||
permissionServerManage,
|
||||
permissionAdminsManage,
|
||||
}
|
||||
}
|
||||
|
||||
// scopedRoute is the only way an API route should be registered. Requiring the
|
||||
// permission as an argument is what makes the panel deny-by-default: a route
|
||||
// cannot be added without someone stating which right it belongs to, so the
|
||||
// failure mode of forgetting is a compile error rather than an endpoint that
|
||||
// quietly answers to everyone.
|
||||
//
|
||||
// permissionSessionOnly is the deliberate exception, spelled out at each use.
|
||||
func (s *server) scopedRoute(permission string, handler http.Handler) http.Handler {
|
||||
if permission == permissionSessionOnly {
|
||||
return s.requireAuthAPI(handler)
|
||||
}
|
||||
return s.requireAuthAPI(s.requirePermission(permission, handler))
|
||||
}
|
||||
|
||||
// scopedRouteAll is scopedRoute for a route that needs more than one right at
|
||||
// once -- taking a granted verification badge away needs both the right to work
|
||||
// the queue and the separate right to revoke. Every permission must be held;
|
||||
// they are requirements, not alternatives.
|
||||
func (s *server) scopedRouteAll(permissions []string, handler http.Handler) http.Handler {
|
||||
if len(permissions) == 0 {
|
||||
// Refusing outright beats silently degrading to "any session": an empty
|
||||
// list here is a mistake at the call site, not a way to open a route.
|
||||
panic("scopedRouteAll: no permissions given")
|
||||
}
|
||||
wrapped := handler
|
||||
for i := len(permissions) - 1; i >= 0; i-- {
|
||||
wrapped = s.requirePermission(permissions[i], wrapped)
|
||||
}
|
||||
return s.requireAuthAPI(wrapped)
|
||||
}
|
||||
|
||||
type permissionsKey struct{}
|
||||
|
||||
// requireAuthAPI is the gate on every authenticated API route: a valid session,
|
||||
|
|
@ -183,46 +67,12 @@ func (s *server) requireAuthAPI(next http.Handler) http.Handler {
|
|||
if !checkMutationSafety(w, r, claims) {
|
||||
return
|
||||
}
|
||||
// Rights inside the cookie are a 12-hour snapshot; the account they
|
||||
// belong to may have been disabled, demoted or had its password changed
|
||||
// since. Re-read it and use what the database says now, so revocation
|
||||
// takes effect on the next request rather than at session expiry.
|
||||
permissions, ok := s.currentSessionPermissions(r.Context(), claims)
|
||||
if !ok {
|
||||
clearSessionCookie(w)
|
||||
writeAPIError(w, http.StatusUnauthorized, "session is no longer valid")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), actorKey{}, claims.Actor)
|
||||
ctx = context.WithValue(ctx, permissionsKey{}, permissions)
|
||||
ctx = context.WithValue(ctx, permissionsKey{}, newPanelPermissions(claims.Permissions))
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// currentSessionPermissions resolves the rights this request actually gets.
|
||||
//
|
||||
// The break-glass operator (UserID 0) has no database row and keeps the
|
||||
// configured set -- that login exists precisely for when the database cannot
|
||||
// be consulted, so it must not depend on one.
|
||||
//
|
||||
// A named account is re-read every request. Anything that moved its token
|
||||
// epoch invalidates the session; anything that narrowed its permissions
|
||||
// narrows this request. A read failure is treated as a refusal rather than as
|
||||
// permission, so a database outage cannot silently widen access.
|
||||
func (s *server) currentSessionPermissions(ctx context.Context, claims sessionClaims) (panelPermissions, bool) {
|
||||
if claims.UserID == 0 {
|
||||
return newPanelPermissions(claims.Permissions), true
|
||||
}
|
||||
if s.read == nil {
|
||||
return panelPermissions{}, false
|
||||
}
|
||||
enabled, epoch, permissions, err := s.read.AdminConsoleSessionState(ctx, claims.UserID)
|
||||
if err != nil || !enabled || epoch != claims.Epoch {
|
||||
return panelPermissions{}, false
|
||||
}
|
||||
return newPanelPermissions(permissions), true
|
||||
}
|
||||
|
||||
// requirePermission refuses a session that was not granted the right, before the
|
||||
// request ever reaches the admin API. The panel is the only caller that can be
|
||||
// driven by a browser, so the check belongs here as well as upstream: a 403 from
|
||||
|
|
|
|||
|
|
@ -18,15 +18,9 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tlprofile"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/hoststats"
|
||||
"telesrv/internal/identity"
|
||||
"telesrv/internal/procctl"
|
||||
)
|
||||
|
||||
//go:embed web/dist
|
||||
|
|
@ -38,8 +32,6 @@ type server struct {
|
|||
hostStats *hoststats.Poller
|
||||
web fs.FS
|
||||
webServer http.Handler
|
||||
identity *identity.Store
|
||||
serverCtl *procctl.Manager
|
||||
}
|
||||
|
||||
func newServer(cfg uiConfig, read *readStore, hostStats *hoststats.Poller) (*server, error) {
|
||||
|
|
@ -53,8 +45,6 @@ func newServer(cfg uiConfig, read *readStore, hostStats *hoststats.Poller) (*ser
|
|||
hostStats: hostStats,
|
||||
web: web,
|
||||
webServer: http.FileServer(http.FS(web)),
|
||||
identity: identity.NewStore(cfg.IdentityDir),
|
||||
serverCtl: procctl.NewManager(cfg.RepoRoot),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -64,106 +54,88 @@ func (s *server) routes() http.Handler {
|
|||
// Logout goes through the same gate as every other mutating route: a forced
|
||||
// logout is a state change, and an invalid session is cleared by the gate
|
||||
// itself, so nothing is stranded by protecting it.
|
||||
mux.Handle("POST /api/logout", s.scopedRoute(permissionSessionOnly, http.HandlerFunc(s.handleAPILogout)))
|
||||
// Unauthenticated on purpose: the login screen shows which server it is,
|
||||
// and the name/icon are already public from owpengram-server's own client
|
||||
// endpoints. See publicbranding.go.
|
||||
mux.HandleFunc("GET /api/public/branding", s.handlePublicBrandingAPI)
|
||||
mux.HandleFunc("GET /api/public/icon", s.handlePublicIconAPI)
|
||||
|
||||
mux.Handle("GET /api/session", s.scopedRoute(permissionSessionOnly, http.HandlerFunc(s.handleSession)))
|
||||
|
||||
// Operator accounts. Every one of these is gated on admins.manage -- the
|
||||
// right that can hand out every other right -- so they are registered
|
||||
// together rather than scattered among the domain routes.
|
||||
mux.Handle("GET /api/admin-users", s.requireAdminsManage(http.HandlerFunc(s.handleListAdminUsersAPI)))
|
||||
// Mutations live under /api/actions/* like every other command in the
|
||||
// panel, so they get the same reason + dry-run + confirm flow.
|
||||
mux.Handle("POST /api/actions/create-admin-operator", s.requireAdminsManage(http.HandlerFunc(s.handleCreateAdminUserAPI)))
|
||||
mux.Handle("POST /api/actions/set-admin-operator-access", s.requireAdminsManage(http.HandlerFunc(s.handleUpdateAdminUserAPI)))
|
||||
mux.Handle("POST /api/actions/set-admin-operator-password", s.requireAdminsManage(http.HandlerFunc(s.handleSetAdminUserPasswordAPI)))
|
||||
mux.Handle("GET /api/dashboard", s.scopedRoute(permissionDashboardRead, http.HandlerFunc(s.handleDashboardAPI)))
|
||||
mux.Handle("GET /api/accounts", s.scopedRoute(permissionAccountsRead, http.HandlerFunc(s.handleAccountsAPI)))
|
||||
mux.Handle("GET /api/accounts/stats", s.scopedRoute(permissionAccountsRead, http.HandlerFunc(s.handleAccountsStatsAPI)))
|
||||
mux.Handle("GET /api/accounts/shared-devices", s.scopedRoute(permissionAccountsRead, http.HandlerFunc(s.handleSharedDeviceGroupsAPI)))
|
||||
mux.Handle("GET /api/broadcasts", s.scopedRoute(permissionBroadcastsRead, http.HandlerFunc(s.handleBroadcastsAPI)))
|
||||
mux.Handle("GET /api/accounts/{id}", s.scopedRoute(permissionAccountsRead, http.HandlerFunc(s.handleAccountDetailAPI)))
|
||||
mux.Handle("GET /api/accounts/{id}/avatar", s.scopedRoute(permissionAccountsRead, http.HandlerFunc(s.handleAccountAvatarAPI)))
|
||||
mux.Handle("GET /api/channels", s.scopedRoute(permissionChannelsRead, http.HandlerFunc(s.handleChannelsAPI)))
|
||||
mux.Handle("GET /api/channels/{id}", s.scopedRoute(permissionChannelsRead, http.HandlerFunc(s.handleChannelDetailAPI)))
|
||||
mux.Handle("GET /api/channels/{id}/avatar", s.scopedRoute(permissionChannelsRead, http.HandlerFunc(s.handleChannelAvatarAPI)))
|
||||
mux.Handle("GET /api/bots", s.scopedRoute(permissionBotsRead, http.HandlerFunc(s.handleBotsAPI)))
|
||||
mux.Handle("GET /api/bots/{id}", s.scopedRoute(permissionBotsRead, http.HandlerFunc(s.handleBotDetailAPI)))
|
||||
mux.Handle("GET /api/emoji", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleEmojiAPI)))
|
||||
mux.Handle("GET /api/emoji/{id}/animation", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleEmojiAnimationAPI)))
|
||||
mux.Handle("GET /api/messages", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleMessagesAPI)))
|
||||
mux.Handle("GET /api/messages/detail", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleMessageDetailAPI)))
|
||||
mux.Handle("GET /api/messages/groups", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleGroupMessagesAPI)))
|
||||
mux.Handle("GET /api/messages/groups/detail", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleGroupMessageDetailAPI)))
|
||||
mux.Handle("GET /api/collectible-usernames", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleCollectibleUsernamesAPI)))
|
||||
mux.Handle("GET /api/reserved-usernames", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleReservedUsernamesAPI)))
|
||||
mux.Handle("GET /api/collectible-usernames/{id}", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleCollectibleUsernameDetailAPI)))
|
||||
mux.Handle("GET /api/storage/stats", s.scopedRoute(permissionStorageRead, http.HandlerFunc(s.handleStorageStatsAPI)))
|
||||
mux.Handle("GET /api/storage/accounts", s.scopedRoute(permissionStorageRead, http.HandlerFunc(s.handleStorageAccountsAPI)))
|
||||
mux.Handle("GET /api/moderation/cases", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleModerationCasesAPI)))
|
||||
mux.Handle("GET /api/moderation/cases/{id}", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleModerationCaseAPI)))
|
||||
mux.Handle("GET /api/moderation/reports/{id}", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleModerationReportAPI)))
|
||||
mux.Handle("POST /api/moderation/cases/{id}/claim", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleClaimModerationCaseAPI)))
|
||||
mux.Handle("POST /api/moderation/cases/{id}/decide", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleDecideModerationCaseAPI)))
|
||||
mux.Handle("POST /api/moderation/cases/{id}/appeals/{appeal_id}/review", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleReviewModerationAppealAPI)))
|
||||
mux.Handle("POST /api/actions/set-frozen", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetAccountFrozenAPI)))
|
||||
mux.Handle("POST /api/actions/grant-premium", s.scopedRoute(permissionPremiumManage, http.HandlerFunc(s.handleGrantPremiumAPI)))
|
||||
mux.Handle("POST /api/actions/set-verified", s.scopedRoute(permissionVerificationReview, http.HandlerFunc(s.handleSetVerifiedAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-flags", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUserFlagsAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-flags", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelFlagsAPI)))
|
||||
mux.Handle("POST /api/actions/set-support", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetSupportAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-username", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-profile", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetProfileAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-phone", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetPhoneAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-avatar", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetAccountAvatarAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-avatar-video", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetAccountAvatarVideoAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-login-email", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetLoginEmailAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-color", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUserColorAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-emoji-status", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUserEmojiStatusAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-avatar", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelAvatarAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-settings", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelSettingsAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-username", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-color", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelColorAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-emoji-status", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelEmojiStatusAPI)))
|
||||
mux.Handle("POST /api/actions/create-bot", s.scopedRoute(permissionBotsManage, http.HandlerFunc(s.handleCreateBotAPI)))
|
||||
mux.Handle("POST /api/actions/create-broadcast", s.scopedRoute(permissionBroadcastsSend, http.HandlerFunc(s.handleCreateBroadcastAPI)))
|
||||
mux.Handle("POST /api/actions/delete-bot", s.scopedRoute(permissionBotsManage, http.HandlerFunc(s.handleDeleteBotAPI)))
|
||||
mux.Handle("POST /api/actions/export-bot-token", s.scopedRoute(permissionBotTokenRead, http.HandlerFunc(s.handleExportBotTokenAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-verified", s.scopedRoute(permissionVerificationReview, http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
|
||||
mux.Handle("POST /api/actions/revoke-sessions", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleRevokeSessionsAPI)))
|
||||
mux.Handle("POST /api/actions/delete-messages", s.scopedRoute(permissionMessagesManage, http.HandlerFunc(s.handleDeleteMessagesAPI)))
|
||||
mux.Handle("POST /api/actions/delete-history", s.scopedRoute(permissionMessagesManage, http.HandlerFunc(s.handleDeleteHistoryAPI)))
|
||||
mux.Handle("GET /api/stickers", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleStickerSetsAPI)))
|
||||
mux.Handle("GET /api/stickers/{id}/documents", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleStickerSetDocumentsAPI)))
|
||||
mux.Handle("GET /api/stickers/documents/{id}/animation", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleStickerDocumentAnimationAPI)))
|
||||
mux.Handle("GET /api/gif-catalog/documents/{id}/preview", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleGifCatalogDocumentPreviewAPI)))
|
||||
mux.Handle("POST /api/actions/set-sticker-set-archived", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleSetStickerSetArchivedAPI)))
|
||||
mux.Handle("POST /api/actions/set-sticker-set-sort-order", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleSetStickerSetSortOrderAPI)))
|
||||
mux.Handle("POST /api/actions/rename-sticker-set", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleRenameStickerSetAPI)))
|
||||
mux.Handle("POST /api/actions/delete-sticker-set", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleDeleteStickerSetAPI)))
|
||||
mux.Handle("POST /api/actions/create-sticker-set", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleCreateStickerSetAPI)))
|
||||
mux.Handle("POST /api/actions/add-sticker-to-set", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleAddStickerToSetAPI)))
|
||||
mux.Handle("POST /api/actions/remove-sticker-from-set", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleRemoveStickerFromSetAPI)))
|
||||
mux.Handle("GET /api/gif-catalog", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleGifCatalogAPI)))
|
||||
mux.Handle("POST /api/actions/create-gif-catalog-entry", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleCreateGifCatalogEntryAPI)))
|
||||
mux.Handle("POST /api/actions/set-gif-catalog-enabled", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleSetGifCatalogEnabledAPI)))
|
||||
mux.Handle("POST /api/actions/set-gif-catalog-sort-order", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleSetGifCatalogSortOrderAPI)))
|
||||
mux.Handle("POST /api/actions/set-gif-catalog-category", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleSetGifCatalogCategoryAPI)))
|
||||
mux.Handle("POST /api/actions/auto-categorize-gif-catalog", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleAutoCategorizeGifCatalogAPI)))
|
||||
mux.Handle("POST /api/actions/delete-uncategorized-gifs", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleDeleteUncategorizedGifsAPI)))
|
||||
mux.Handle("POST /api/actions/storage-manual-purge", s.scopedRoute(permissionStorageManage, http.HandlerFunc(s.handleStorageManualPurgeAPI)))
|
||||
mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI)))
|
||||
mux.Handle("POST /api/actions/reserve-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleReserveUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/unreserve-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleUnreserveUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/mint-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/transfer-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/revoke-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/delete-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleDeleteCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/logout", s.requireAuthAPI(http.HandlerFunc(s.handleAPILogout)))
|
||||
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession)))
|
||||
mux.Handle("GET /api/dashboard", s.requireAuthAPI(http.HandlerFunc(s.handleDashboardAPI)))
|
||||
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
|
||||
mux.Handle("GET /api/accounts/stats", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsStatsAPI)))
|
||||
mux.Handle("GET /api/accounts/shared-devices", s.requireAuthAPI(http.HandlerFunc(s.handleSharedDeviceGroupsAPI)))
|
||||
mux.Handle("GET /api/broadcasts", s.requireAuthAPI(http.HandlerFunc(s.handleBroadcastsAPI)))
|
||||
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
|
||||
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
|
||||
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
|
||||
mux.Handle("GET /api/channels/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleChannelDetailAPI)))
|
||||
mux.Handle("GET /api/channels/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleChannelAvatarAPI)))
|
||||
mux.Handle("GET /api/bots", s.requireAuthAPI(http.HandlerFunc(s.handleBotsAPI)))
|
||||
mux.Handle("GET /api/bots/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleBotDetailAPI)))
|
||||
mux.Handle("GET /api/emoji", s.requireAuthAPI(http.HandlerFunc(s.handleEmojiAPI)))
|
||||
mux.Handle("GET /api/emoji/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleEmojiAnimationAPI)))
|
||||
mux.Handle("GET /api/messages", s.requireAuthAPI(http.HandlerFunc(s.handleMessagesAPI)))
|
||||
mux.Handle("GET /api/messages/detail", s.requireAuthAPI(http.HandlerFunc(s.handleMessageDetailAPI)))
|
||||
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
|
||||
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
|
||||
mux.Handle("GET /api/collectible-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernamesAPI)))
|
||||
mux.Handle("GET /api/reserved-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleReservedUsernamesAPI)))
|
||||
mux.Handle("GET /api/collectible-usernames/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernameDetailAPI)))
|
||||
mux.Handle("GET /api/storage/stats", s.requireAuthAPI(http.HandlerFunc(s.handleStorageStatsAPI)))
|
||||
mux.Handle("GET /api/storage/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleStorageAccountsAPI)))
|
||||
mux.Handle("GET /api/moderation/cases", s.requireAuthAPI(http.HandlerFunc(s.handleModerationCasesAPI)))
|
||||
mux.Handle("GET /api/moderation/cases/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleModerationCaseAPI)))
|
||||
mux.Handle("GET /api/moderation/reports/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleModerationReportAPI)))
|
||||
mux.Handle("POST /api/moderation/cases/{id}/claim", s.requireAuthAPI(http.HandlerFunc(s.handleClaimModerationCaseAPI)))
|
||||
mux.Handle("POST /api/moderation/cases/{id}/decide", s.requireAuthAPI(http.HandlerFunc(s.handleDecideModerationCaseAPI)))
|
||||
mux.Handle("POST /api/moderation/cases/{id}/appeals/{appeal_id}/review", s.requireAuthAPI(http.HandlerFunc(s.handleReviewModerationAppealAPI)))
|
||||
mux.Handle("POST /api/actions/set-frozen", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountFrozenAPI)))
|
||||
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
|
||||
mux.Handle("POST /api/actions/set-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetVerifiedAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserFlagsAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelFlagsAPI)))
|
||||
mux.Handle("POST /api/actions/set-support", s.requireAuthAPI(http.HandlerFunc(s.handleSetSupportAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-profile", s.requireAuthAPI(http.HandlerFunc(s.handleSetProfileAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-phone", s.requireAuthAPI(http.HandlerFunc(s.handleSetPhoneAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-avatar", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountAvatarAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-login-email", s.requireAuthAPI(http.HandlerFunc(s.handleSetLoginEmailAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserColorAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserEmojiStatusAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-avatar", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelAvatarAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-settings", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelSettingsAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelColorAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelEmojiStatusAPI)))
|
||||
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/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)))
|
||||
mux.Handle("POST /api/actions/delete-history", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteHistoryAPI)))
|
||||
mux.Handle("GET /api/stickers", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetsAPI)))
|
||||
mux.Handle("GET /api/stickers/{id}/documents", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetDocumentsAPI)))
|
||||
mux.Handle("GET /api/stickers/documents/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStickerDocumentAnimationAPI)))
|
||||
mux.Handle("GET /api/gif-catalog/documents/{id}/preview", s.requireAuthAPI(http.HandlerFunc(s.handleGifCatalogDocumentPreviewAPI)))
|
||||
mux.Handle("POST /api/actions/set-sticker-set-archived", s.requireAuthAPI(http.HandlerFunc(s.handleSetStickerSetArchivedAPI)))
|
||||
mux.Handle("POST /api/actions/set-sticker-set-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStickerSetSortOrderAPI)))
|
||||
mux.Handle("POST /api/actions/rename-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleRenameStickerSetAPI)))
|
||||
mux.Handle("POST /api/actions/delete-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteStickerSetAPI)))
|
||||
mux.Handle("POST /api/actions/create-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleCreateStickerSetAPI)))
|
||||
mux.Handle("POST /api/actions/add-sticker-to-set", s.requireAuthAPI(http.HandlerFunc(s.handleAddStickerToSetAPI)))
|
||||
mux.Handle("POST /api/actions/remove-sticker-from-set", s.requireAuthAPI(http.HandlerFunc(s.handleRemoveStickerFromSetAPI)))
|
||||
mux.Handle("GET /api/gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleGifCatalogAPI)))
|
||||
mux.Handle("POST /api/actions/create-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleCreateGifCatalogEntryAPI)))
|
||||
mux.Handle("POST /api/actions/set-gif-catalog-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogEnabledAPI)))
|
||||
mux.Handle("POST /api/actions/set-gif-catalog-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogSortOrderAPI)))
|
||||
mux.Handle("POST /api/actions/set-gif-catalog-category", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogCategoryAPI)))
|
||||
mux.Handle("POST /api/actions/auto-categorize-gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleAutoCategorizeGifCatalogAPI)))
|
||||
mux.Handle("POST /api/actions/delete-uncategorized-gifs", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteUncategorizedGifsAPI)))
|
||||
mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI)))
|
||||
mux.Handle("POST /api/actions/reserve-username", s.requireAuthAPI(http.HandlerFunc(s.handleReserveUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/unreserve-username", s.requireAuthAPI(http.HandlerFunc(s.handleUnreserveUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/delete-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteCollectibleUsernameAPI)))
|
||||
// Official platform verification. Every route needs verification.review;
|
||||
// clearing an existing badge needs verification.revoke on top of it.
|
||||
mux.Handle("GET /api/verification/applications", s.verificationRead(s.handleVerificationApplicationsAPI))
|
||||
|
|
@ -172,9 +144,9 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/verification/applications/{id}/claim", s.verificationRead(s.handleClaimVerificationAPI))
|
||||
mux.Handle("POST /api/verification/applications/{id}/approve", s.verificationRead(s.handleApproveVerificationAPI))
|
||||
mux.Handle("POST /api/verification/applications/{id}/reject", s.verificationRead(s.handleRejectVerificationAPI))
|
||||
mux.Handle("POST /api/actions/revoke-verification", s.scopedRouteAll(
|
||||
[]string{permissionVerificationReview, permissionVerificationRevoke},
|
||||
http.HandlerFunc(s.handleRevokeVerificationAPI)))
|
||||
mux.Handle("POST /api/actions/revoke-verification", s.requireAuthAPI(
|
||||
s.requirePermission(permissionVerificationReview,
|
||||
s.requirePermission(permissionVerificationRevoke, http.HandlerFunc(s.handleRevokeVerificationAPI)))))
|
||||
// Third-party bot verification. A separate section from the official
|
||||
// verification block above -- separate tables, separate rights, separate routes.
|
||||
// Reads and queue decisions need botverification.review; appointing verifiers,
|
||||
|
|
@ -195,26 +167,6 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/upsert-verification-icon", s.botVerificationManage(s.handleUpsertVerificationIconAPI))
|
||||
mux.Handle("POST /api/actions/set-verification-icon-active", s.botVerificationManage(s.handleSetVerificationIconActiveAPI))
|
||||
mux.Handle("POST /api/actions/revoke-custom-verification", s.botVerificationManage(s.handleRevokeCustomVerificationAPI))
|
||||
// Server Settings -- see serversettings.go. Everything here operates
|
||||
// directly on local files/processes (no RPC hop to owpengram-server's
|
||||
// in-process admin API), so it works even for actions (Restart/Update)
|
||||
// that owpengram-server could never safely perform on itself.
|
||||
mux.Handle("GET /api/server/identity", s.serverManage(s.handleServerIdentityAPI))
|
||||
mux.Handle("GET /api/server/add-server-link", s.serverManage(s.handleAddServerLinkAPI))
|
||||
mux.Handle("GET /api/server/icon", s.serverManage(s.handleServerIconAPI))
|
||||
mux.Handle("POST /api/actions/set-server-identity", s.serverManage(s.handleSetServerIdentityAPI))
|
||||
mux.Handle("POST /api/actions/set-welcome-message-templates", s.serverManage(s.handleSetWelcomeMessageTemplatesAPI))
|
||||
mux.Handle("POST /api/actions/set-login-code-message-template", s.serverManage(s.handleSetLoginCodeMessageTemplateAPI))
|
||||
mux.Handle("POST /api/actions/upload-server-icon", s.serverManage(s.handleUploadServerIconAPI))
|
||||
mux.Handle("POST /api/actions/remove-server-icon", s.serverManage(s.handleRemoveServerIconAPI))
|
||||
mux.Handle("POST /api/actions/complete-setup", s.serverManage(s.handleCompleteSetupAPI))
|
||||
mux.Handle("GET /api/server/env", s.serverManage(s.handleServerEnvAPI))
|
||||
mux.Handle("POST /api/actions/update-server-env", s.serverManage(s.handleUpdateServerEnvAPI))
|
||||
mux.Handle("GET /api/server/status", s.serverManage(s.handleServerStatusAPI))
|
||||
mux.Handle("GET /api/server/docker-status", s.serverManage(s.handleDockerStatusAPI))
|
||||
mux.Handle("GET /api/server/check-updates", s.serverManage(s.handleCheckServerUpdatesAPI))
|
||||
mux.Handle("POST /api/actions/restart-server", s.serverManage(s.handleRestartServerAPI))
|
||||
mux.Handle("POST /api/actions/update-server", s.serverManage(s.handleUpdateServerAPI))
|
||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeAPIError(w, http.StatusNotFound, "api route not found")
|
||||
})
|
||||
|
|
@ -228,11 +180,7 @@ func actorFromContext(ctx context.Context) string {
|
|||
if actor, ok := ctx.Value(actorKey{}).(string); ok && actor != "" {
|
||||
return actor
|
||||
}
|
||||
// requireAuthAPI always puts the actor in the context, so this is
|
||||
// unreachable in practice. It returns a name that is obviously not a real
|
||||
// operator rather than a plausible one: an audit line reading "admin" would
|
||||
// silently attribute the action to somebody.
|
||||
return "unknown"
|
||||
return "admin"
|
||||
}
|
||||
|
||||
func (s *server) handleApp(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -249,12 +197,7 @@ func (s *server) handleApp(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
type loginRequest struct {
|
||||
// Username selects a named account in admin_console_users. Left empty, the
|
||||
// credential is checked against TELESRV_ADMIN_UI_PASSWORD / _TOKEN instead,
|
||||
// which keeps the pre-accounts login working and doubles as the way back in
|
||||
// if the database is unreachable or every named account is locked out.
|
||||
Username string `json:"username"`
|
||||
Secret string `json:"secret"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
// sessionTTL bounds a signed panel session and the CSRF cookie that goes with it,
|
||||
|
|
@ -274,11 +217,7 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
|
|||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
identity, ok := s.authenticateLogin(r.Context(), req)
|
||||
if !ok {
|
||||
// One message and one status for every failure mode -- unknown account,
|
||||
// wrong password, disabled account. Saying which would let anyone with
|
||||
// the login form enumerate operators.
|
||||
if !s.validSecret(req.Secret) {
|
||||
writeAPIError(w, http.StatusUnauthorized, "invalid credential")
|
||||
return
|
||||
}
|
||||
|
|
@ -287,11 +226,9 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
|
|||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
permissions := newPanelPermissions(identity.permissions)
|
||||
permissions := newPanelPermissions(s.cfg.Permissions)
|
||||
value, err := signSession(s.cfg.SessionKey, sessionClaims{
|
||||
Actor: identity.actor,
|
||||
UserID: identity.userID,
|
||||
Epoch: identity.epoch,
|
||||
Actor: "admin",
|
||||
Exp: time.Now().Add(sessionTTL).Unix(),
|
||||
Nonce: newCommandID("sess"),
|
||||
Permissions: permissions.List(),
|
||||
|
|
@ -311,7 +248,7 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
setCSRFCookie(w, csrfToken, sessionTTL)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"actor": identity.actor,
|
||||
"actor": "admin",
|
||||
"permissions": permissions.List(),
|
||||
"csrf_token": csrfToken,
|
||||
"hide_third_party_verification": s.cfg.HideThirdPartyVerification,
|
||||
|
|
@ -320,16 +257,6 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (s *server) validSecret(secret string) bool {
|
||||
if s.cfg.Password != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Password)) == 1 {
|
||||
// The password quickstart auto-generates for the very first login
|
||||
// (see identity.Store.TemporaryPasswordMatches) is only good until
|
||||
// the first-run wizard finishes -- one login's worth of "how do I
|
||||
// even get in", not a credential anyone actually chose to keep
|
||||
// around. A password an operator set on purpose, whether by saving
|
||||
// one from Server Settings or editing .env by hand, never matches
|
||||
// the stored generated value, so this never touches it.
|
||||
if s.identity.TemporaryPasswordMatches(s.cfg.Password) && !s.identity.SetupPending() {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if s.cfg.Token != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Token)) == 1 {
|
||||
|
|
@ -343,56 +270,14 @@ func (s *server) handleAPILogout(w http.ResponseWriter, _ *http.Request) {
|
|||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// supportedTLLayers returns every MTProto TL schema layer this server binary
|
||||
// can admit and encode for, oldest first. Probes tlprofile.ResolveProfile
|
||||
// (the vendored td package's registry) rather than hardcoding a range here,
|
||||
// so this stays correct as new profiles get added upstream without a
|
||||
// second place to remember to update. tg.Layer is always the newest one and
|
||||
// is included by construction, since ResolveProfile(tg.Layer) must succeed.
|
||||
func supportedTLLayers() []int {
|
||||
var layers []int
|
||||
for n := 1; n <= tg.Layer; n++ {
|
||||
if _, ok := tlprofile.ResolveProfile(n); ok {
|
||||
layers = append(layers, n)
|
||||
}
|
||||
}
|
||||
return layers
|
||||
}
|
||||
|
||||
// handleSession is what the panel asks on load. It reports the permissions the
|
||||
// session carries, so the UI can hide a section the operator may not use rather
|
||||
// than letting them walk into a 403.
|
||||
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||
build := currentBuildMetadata()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"actor": actorFromContext(r.Context()),
|
||||
"permissions": permissionsFromContext(r.Context()).List(),
|
||||
"hide_third_party_verification": s.cfg.HideThirdPartyVerification,
|
||||
// setup_completed gates the first-run wizard -- see
|
||||
// identity.Store.SetupPending's doc comment for why this reads a
|
||||
// sentinel file rather than anything in identity.json itself.
|
||||
"setup_completed": !s.identity.SetupPending(),
|
||||
// boot_id is random per process start (see main.go) -- Server
|
||||
// Settings' Restart/Update flow polls this after triggering an
|
||||
// action and reloads the page once it changes, which is how it
|
||||
// tells "the old admin process died and a new one answered" apart
|
||||
// from "the old one is just slow to respond".
|
||||
"boot_id": bootID,
|
||||
// api_layers is every MTProto TL schema layer this server binary can
|
||||
// actually admit and encode for (see tlprofile.ResolveProfile in the
|
||||
// vendored td package) -- the server is multi-layer (a client on an
|
||||
// older supported layer still works, not just the newest one), so
|
||||
// the sidebar shows the whole supported set, not just tg.Layer.
|
||||
"api_layers": supportedTLLayers(),
|
||||
// build is this admin binary's own commit -- shown under "Version"
|
||||
// in the sidebar footer so an operator can tell at a glance which
|
||||
// build is actually running, independent of the app version string.
|
||||
"build": map[string]any{
|
||||
"commit": build.Commit,
|
||||
"short_commit": build.shortCommit(),
|
||||
"dirty": build.Dirty,
|
||||
"build_time": build.BuildTime,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -404,26 +289,13 @@ func (s *server) handleDashboardAPI(w http.ResponseWriter, r *http.Request) {
|
|||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
// The two halves hit different tables and neither feeds the other, so the
|
||||
// page waited for their sum for no reason. Storage in particular is the
|
||||
// expensive one; running it alongside the counts means the response costs
|
||||
// whichever is slower rather than both.
|
||||
var (
|
||||
counts DashboardCounts
|
||||
storage StorageStatsRow
|
||||
)
|
||||
g, gctx := errgroup.WithContext(r.Context())
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
counts, err = s.read.DashboardCounts(gctx)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
storage, err = s.read.StorageStats(gctx)
|
||||
return err
|
||||
})
|
||||
if err := g.Wait(); err != nil {
|
||||
counts, err := s.read.DashboardCounts(r.Context())
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
storage, err := s.read.StorageStats(r.Context())
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
|
@ -1030,13 +902,10 @@ type createBroadcastAPIRequest struct {
|
|||
UserIDs []int64 `json:"user_ids,omitempty"`
|
||||
}
|
||||
|
||||
// handleCreateBroadcastAPI forwards a broadcast create straight to the admin
|
||||
// API. "all" mode is no longer pre-resolved into an explicit id list here:
|
||||
// the admin service snapshots the current eligible user set itself and the
|
||||
// broadcast worker enumerates it incrementally, so "every user" never has
|
||||
// to cross this boundary (or the one after it) as a potentially huge id
|
||||
// slice. Only "selected" mode carries UserIDs, already an operator-picked
|
||||
// list bounded by domain.MaxBroadcastSelectedRecipients.
|
||||
// handleCreateBroadcastAPI resolves "all users" into an explicit id list
|
||||
// before forwarding to the admin API: the admin service always receives an
|
||||
// already-resolved recipient list, never "every user" as a live concept it
|
||||
// would have to know how to enumerate itself.
|
||||
func (s *server) handleCreateBroadcastAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body createBroadcastAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
|
|
@ -1044,7 +913,16 @@ func (s *server) handleCreateBroadcastAPI(w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
userIDs := body.UserIDs
|
||||
if body.TargetMode == "all" {
|
||||
userIDs = nil
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
all, err := s.read.ListAllAccountIDs(r.Context())
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
userIDs = all
|
||||
}
|
||||
req := admin.CreateBroadcastRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "create-broadcast"),
|
||||
|
|
@ -1532,52 +1410,6 @@ func (s *server) handleSetAccountAvatarAPI(w http.ResponseWriter, r *http.Reques
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setAccountAvatarVideoAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
VideoStartTs float64 `json:"video_start_ts"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetAccountAvatarVideoAPI(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarVideoBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var body setAccountAvatarVideoAPIRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "avatar video file is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarVideoBytes+1))
|
||||
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarVideoBytes {
|
||||
writeAPIError(w, http.StatusBadRequest, "avatar video file is empty or too large")
|
||||
return
|
||||
}
|
||||
req := admin.SetAccountAvatarVideoRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-avatar-video"),
|
||||
UserID: body.UserID,
|
||||
FileName: header.Filename,
|
||||
VideoStartTs: body.VideoStartTs,
|
||||
}
|
||||
result, err := s.callAdminMultipart(r.Context(), "/v1/accounts/set-avatar-video", req, header.Filename, data)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setUserColorAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
|
|
@ -2152,37 +1984,6 @@ func (s *server) handleDeleteUncategorizedGifsAPI(w http.ResponseWriter, r *http
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
// storageManualPurgeAPIRequest mirrors ManualPurgeStorageRequest's frontend
|
||||
// payload (see StoragePage.tsx's manual purge modal): categories/include_avatars
|
||||
// go through as native JSON, created_before is an ISO/RFC3339 date string
|
||||
// (produced by `new Date(x).toISOString()` on the frontend, same convention
|
||||
// as freeze_until -- see setAccountFrozenAPIRequest.Until) that encoding/json
|
||||
// parses straight into *time.Time; the field left undefined by the frontend
|
||||
// (no date entered) decodes to nil, meaning no age filter at all.
|
||||
type storageManualPurgeAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Categories []string `json:"categories"`
|
||||
IncludeAvatars bool `json:"include_avatars"`
|
||||
CreatedBefore *time.Time `json:"created_before"`
|
||||
}
|
||||
|
||||
func (s *server) handleStorageManualPurgeAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body storageManualPurgeAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.ManualPurgeStorageRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "storage-manual-purge"),
|
||||
Categories: body.Categories,
|
||||
IncludeAvatars: body.IncludeAvatars,
|
||||
CreatedBefore: body.CreatedBefore,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/storage/manual-purge", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type deleteGifCatalogEntryAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
|
|
@ -2704,14 +2505,7 @@ func (s *server) handleStorageAccountsAPI(w http.ResponseWriter, r *http.Request
|
|||
writeAPIError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
sortDesc := query.Get("order") != "asc"
|
||||
rows, hasMore, err := s.read.ListAccountStorageUsage(
|
||||
r.Context(),
|
||||
query.Get("q"),
|
||||
query.Get("sort"),
|
||||
sortDesc,
|
||||
offset,
|
||||
limit)
|
||||
rows, hasMore, err := s.read.ListAccountStorageUsage(r.Context(), offset, limit)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,425 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/identity"
|
||||
)
|
||||
|
||||
// serverManage gates the whole Server Settings surface -- see
|
||||
// permissionServerManage's doc comment in security.go for why this is one
|
||||
// right rather than split review/manage like other sections.
|
||||
func (s *server) serverManage(handler http.HandlerFunc) http.Handler {
|
||||
return s.requireAuthAPI(s.requirePermission(permissionServerManage, handler))
|
||||
}
|
||||
|
||||
// serverCommandResult builds the same admin.CommandResult shape every other
|
||||
// action returns, without going through internal/admin's runCommand +
|
||||
// Postgres audit log: everything in this file operates on local files/
|
||||
// processes directly (see routes() in server.go for why), so there is no
|
||||
// owpengram-server-side admin_commands row to write. The actor/reason are
|
||||
// still in meta for structured logging if that's ever added; today they are
|
||||
// simply not persisted anywhere.
|
||||
func serverCommandResult(meta admin.CommandMeta, action string, err error, message string, details map[string]any) admin.CommandResult {
|
||||
status := "completed"
|
||||
errText := ""
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
errText = err.Error()
|
||||
if message == "" {
|
||||
message = "command failed"
|
||||
}
|
||||
}
|
||||
return admin.CommandResult{
|
||||
CommandID: meta.CommandID,
|
||||
Action: action,
|
||||
Status: status,
|
||||
DryRun: meta.DryRun,
|
||||
Message: message,
|
||||
Details: details,
|
||||
Error: errText,
|
||||
}
|
||||
}
|
||||
|
||||
// --- identity (name/description/icon) ---------------------------------
|
||||
|
||||
// serverIdentityAPIResponse extends identity.Info's raw fields with the two
|
||||
// *effective* fallback welcome-message templates -- s.cfg's
|
||||
// WelcomeMessage{Phone,Email}Default, i.e. this admin process's own reading
|
||||
// of TELESRV_WELCOME_MESSAGE_*_TEMPLATE (env var, itself defaulting to the
|
||||
// compiled-in copy), which matches what owpengram-server falls back to
|
||||
// whenever the panel override is unset, as long as both processes share the
|
||||
// same .env (see uiConfig.WelcomeMessagePhoneDefault's doc comment). The
|
||||
// panel needs both: the raw override (possibly empty) to know whether a
|
||||
// field is "explicitly set", and the default text to show as "(using
|
||||
// default: ...)" / to restore on Reset.
|
||||
type serverIdentityAPIResponse struct {
|
||||
identity.Info
|
||||
DefaultWelcomeMessagePhoneTemplate string `json:"default_welcome_message_phone_template"`
|
||||
DefaultWelcomeMessageEmailTemplate string `json:"default_welcome_message_email_template"`
|
||||
// DefaultLoginCodeMessageTemplate is the effective fallback text for
|
||||
// the login-code delivery message (s.cfg.LoginCodeMessageDefault) --
|
||||
// same "raw override + effective default" contract as the two fields
|
||||
// above, see their doc comment.
|
||||
DefaultLoginCodeMessageTemplate string `json:"default_login_code_message_template"`
|
||||
}
|
||||
|
||||
func (s *server) handleServerIdentityAPI(w http.ResponseWriter, r *http.Request) {
|
||||
info, err := s.identity.Get()
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, serverIdentityAPIResponse{
|
||||
Info: info,
|
||||
DefaultWelcomeMessagePhoneTemplate: s.cfg.WelcomeMessagePhoneDefault,
|
||||
DefaultWelcomeMessageEmailTemplate: s.cfg.WelcomeMessageEmailDefault,
|
||||
DefaultLoginCodeMessageTemplate: s.cfg.LoginCodeMessageDefault,
|
||||
})
|
||||
}
|
||||
|
||||
// handleServerIconAPI serves the icon's raw bytes for the panel's own
|
||||
// preview -- separate from owpengram-server's public /owpengram/server-icon
|
||||
// (same underlying file, different process/auth: this one is behind the
|
||||
// admin session, not open to clients).
|
||||
func (s *server) handleServerIconAPI(w http.ResponseWriter, r *http.Request) {
|
||||
data, ext, ok := s.identity.Icon()
|
||||
if !ok {
|
||||
writeAPIError(w, http.StatusNotFound, "no icon configured")
|
||||
return
|
||||
}
|
||||
contentType := map[string]string{
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".webp": "image/webp", ".gif": "image/gif",
|
||||
}[ext]
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
type setServerIdentityAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetServerIdentityAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setServerIdentityAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-server-identity")
|
||||
details := map[string]any{"name": body.Name, "description": body.Description}
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_identity", nil, "server identity validated", details))
|
||||
return
|
||||
}
|
||||
err := s.identity.SetText(body.Name, body.Description)
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_identity", err, "server identity updated", details))
|
||||
}
|
||||
|
||||
// --- login-notification templates ---------------------------------------
|
||||
|
||||
type setWelcomeMessageTemplatesAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
PhoneTemplate string `json:"phone_template"`
|
||||
EmailTemplate string `json:"email_template"`
|
||||
}
|
||||
|
||||
// handleSetWelcomeMessageTemplatesAPI sets (or, with an empty string,
|
||||
// clears) the admin-panel override for the 777000 login-notification
|
||||
// message's phone/email template -- see identity.Store.SetWelcomeMessageTemplates
|
||||
// and domain.ResolveWelcomeMessageTemplate. Deliberately a separate endpoint
|
||||
// from set-server-identity: brand identity (name/description/icon) and
|
||||
// login-notification copy are different concerns that happen to share the
|
||||
// same on-disk identity.json, and keeping them as separate actions/buttons
|
||||
// means editing one never risks silently blanking the other.
|
||||
func (s *server) handleSetWelcomeMessageTemplatesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setWelcomeMessageTemplatesAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-welcome-message-templates")
|
||||
details := map[string]any{
|
||||
"phone_template_set": strings.TrimSpace(body.PhoneTemplate) != "",
|
||||
"email_template_set": strings.TrimSpace(body.EmailTemplate) != "",
|
||||
}
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_welcome_message_templates", nil, "login-notification templates validated", details))
|
||||
return
|
||||
}
|
||||
err := s.identity.SetWelcomeMessageTemplates(body.PhoneTemplate, body.EmailTemplate)
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_welcome_message_templates", err, "login-notification templates updated", details))
|
||||
}
|
||||
|
||||
// --- login-code delivery message template --------------------------------
|
||||
|
||||
type setLoginCodeMessageTemplateAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Template string `json:"template"`
|
||||
}
|
||||
|
||||
// handleSetLoginCodeMessageTemplateAPI sets (or, with an empty string,
|
||||
// clears) the admin-panel override for the 777000 login-code delivery
|
||||
// message -- see identity.Store.SetLoginCodeMessageTemplate and
|
||||
// domain.ResolveLoginCodeMessageTemplate. A dedicated endpoint (not folded
|
||||
// into set-welcome-message-templates): this message embeds the actual OTP
|
||||
// code via the {{code}} placeholder, so a save here carries an extra,
|
||||
// security-relevant validation the login-notification templates don't
|
||||
// need -- a template missing {{code}} (or containing it more than once)
|
||||
// would either silently drop the code from the message or leave it
|
||||
// ambiguous which occurrence carries it, so it is rejected outright with a
|
||||
// 422 rather than saved. Clearing the override (empty string) is exempt --
|
||||
// it always resolves to a valid built-in/env default.
|
||||
func (s *server) handleSetLoginCodeMessageTemplateAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setLoginCodeMessageTemplateAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if t := strings.TrimSpace(body.Template); t != "" {
|
||||
if err := domain.ValidateLoginCodeMessageTemplate(t); err != nil {
|
||||
writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-login-code-message-template")
|
||||
details := map[string]any{"template_set": strings.TrimSpace(body.Template) != ""}
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_login_code_message_template", nil, "login-code message template validated", details))
|
||||
return
|
||||
}
|
||||
err := s.identity.SetLoginCodeMessageTemplate(body.Template)
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_login_code_message_template", err, "login-code message template updated", details))
|
||||
}
|
||||
|
||||
var allowedServerIconExts = map[string]bool{
|
||||
".png": true, ".jpg": true, ".jpeg": true, ".webp": true, ".gif": true,
|
||||
}
|
||||
|
||||
const maxServerIconBytes = 2 << 20 // 2 MiB
|
||||
|
||||
type uploadServerIconAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
|
||||
// handleUploadServerIconAPI takes multipart/form-data (a "metadata" JSON
|
||||
// field + a "file" field), the same shape handleSetAccountAvatarAPI uses --
|
||||
// deliberately not JSON+base64 like the other Server Settings actions:
|
||||
// base64 inflates a file ~33%, and decodeAction's plain io.LimitReader caps
|
||||
// the request body at 1MiB regardless of maxServerIconBytes, so a real
|
||||
// multi-hundred-KB icon would fail decoding ("unexpected EOF" from the
|
||||
// truncated body) before this handler ever saw it. Multipart sidesteps that
|
||||
// entirely -- the size cap below is enforced on the actual file bytes.
|
||||
func (s *server) handleUploadServerIconAPI(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxServerIconBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var body uploadServerIconAPIRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "icon file is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if !allowedServerIconExts[ext] {
|
||||
writeAPIError(w, http.StatusBadRequest, "unsupported icon extension")
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxServerIconBytes+1))
|
||||
if err != nil || len(data) == 0 || len(data) > maxServerIconBytes {
|
||||
writeAPIError(w, http.StatusBadRequest, "icon file is empty or too large (max 2MiB)")
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "upload-server-icon")
|
||||
details := map[string]any{"bytes": len(data), "ext": ext}
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.upload_icon", nil, "server icon validated", details))
|
||||
return
|
||||
}
|
||||
setErr := s.identity.SetIcon(data, ext)
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.upload_icon", setErr, "server icon updated", details))
|
||||
}
|
||||
|
||||
type removeServerIconAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
|
||||
func (s *server) handleRemoveServerIconAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body removeServerIconAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "remove-server-icon")
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.remove_icon", nil, "server icon removal validated", nil))
|
||||
return
|
||||
}
|
||||
err := s.identity.RemoveIcon()
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.remove_icon", err, "server icon removed", nil))
|
||||
}
|
||||
|
||||
// --- first-run setup wizard ----------------------------------------------
|
||||
|
||||
type completeSetupAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
|
||||
// handleCompleteSetupAPI is the wizard's last step: removes the
|
||||
// identity.Store setup-pending marker so /api/session stops telling the
|
||||
// frontend to show it. Everything the wizard actually configures (identity,
|
||||
// .env, the operator account) is already saved as the operator moves
|
||||
// through it via the same actions Server Settings/Operators use outside the
|
||||
// wizard -- this action only marks that the walkthrough happened, so it
|
||||
// never fails partway through something worth retrying.
|
||||
func (s *server) handleCompleteSetupAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body completeSetupAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "complete-setup")
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.complete_setup", nil, "setup completion validated", nil))
|
||||
return
|
||||
}
|
||||
err := s.identity.MarkSetupComplete()
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.complete_setup", err, "setup marked complete", nil))
|
||||
}
|
||||
|
||||
// --- .env editing --------------------------------------------------------
|
||||
|
||||
func (s *server) handleServerEnvAPI(w http.ResponseWriter, r *http.Request) {
|
||||
groups, err := s.serverCtl.ReadEnvGroups()
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, groups)
|
||||
}
|
||||
|
||||
type updateServerEnvAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Values map[string]string `json:"values"`
|
||||
}
|
||||
|
||||
func (s *server) handleUpdateServerEnvAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body updateServerEnvAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "update-server-env")
|
||||
details := map[string]any{"keys_changed": len(body.Values)}
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.update_env", nil, "would update .env -- takes effect on next Restart/Update", details))
|
||||
return
|
||||
}
|
||||
err := s.serverCtl.WriteEnvValues(body.Values)
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.update_env", err, ".env updated -- restart the server for changes to take effect", details))
|
||||
}
|
||||
|
||||
// --- status / restart / update -------------------------------------------
|
||||
|
||||
func (s *server) handleServerStatusAPI(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.serverCtl.Status())
|
||||
}
|
||||
|
||||
// handleDockerStatusAPI backs the Services tab's live container list
|
||||
// (postgres/redis/minio) -- see procctl.Manager.DockerStatus. A "docker
|
||||
// compose ps" failure (daemon not running, compose file missing) is
|
||||
// reported as an API error rather than an empty list, so the frontend can
|
||||
// tell "no services" apart from "couldn't ask Docker".
|
||||
// handleCheckServerUpdatesAPI backs the Update button's "Check updates"
|
||||
// state -- a plain git fetch + rev-list count, no pull/build/restart. See
|
||||
// procctl.Manager.CheckUpdates.
|
||||
func (s *server) handleCheckServerUpdatesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
behind, err := s.serverCtl.CheckUpdates(r.Context())
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"commits_behind": behind})
|
||||
}
|
||||
|
||||
func (s *server) handleDockerStatusAPI(w http.ResponseWriter, r *http.Request) {
|
||||
services, err := s.serverCtl.DockerStatus(r.Context())
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, services)
|
||||
}
|
||||
|
||||
type restartServerAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
|
||||
func (s *server) handleRestartServerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body restartServerAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "restart-server")
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.restart", nil, "restart validated -- rebuilds both bin/owpengram-server and bin/owpengram-admin-panel, relaunches owpengram-server", nil))
|
||||
return
|
||||
}
|
||||
log, err := s.serverCtl.Restart(r.Context())
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.restart", err, "server restarted", map[string]any{"log": log}))
|
||||
}
|
||||
|
||||
type updateServerAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
|
||||
func (s *server) handleUpdateServerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body updateServerAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "update-server")
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.update", nil, "update validated -- git pull, rebuild both binaries, relaunch bin/owpengram-server (admin panel binary is rebuilt but not self-restarted)", nil))
|
||||
return
|
||||
}
|
||||
log, err := s.serverCtl.Update(r.Context())
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.update", err, "server updated", map[string]any{"log": log}))
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/identity"
|
||||
)
|
||||
|
||||
func TestServerIdentityAPIReportsOverridesAndDefaultsSeparately(t *testing.T) {
|
||||
store := identity.NewStore(t.TempDir())
|
||||
srv := &server{
|
||||
cfg: uiConfig{
|
||||
WelcomeMessagePhoneDefault: "env phone default",
|
||||
WelcomeMessageEmailDefault: "env email default",
|
||||
},
|
||||
identity: store,
|
||||
}
|
||||
|
||||
// Before any override: GET must report empty raw fields (so the UI can
|
||||
// tell "unset" apart from "explicitly set to the same text as the
|
||||
// default"), alongside the effective default text.
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/server/identity", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleServerIdentityAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp serverIdentityAPIResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.WelcomeMessagePhoneTemplate != "" || resp.WelcomeMessageEmailTemplate != "" {
|
||||
t.Fatalf("expected empty overrides before any Set, got %+v", resp)
|
||||
}
|
||||
if resp.DefaultWelcomeMessagePhoneTemplate != "env phone default" || resp.DefaultWelcomeMessageEmailTemplate != "env email default" {
|
||||
t.Fatalf("expected effective defaults from cfg, got %+v", resp)
|
||||
}
|
||||
|
||||
// Set an override for phone only.
|
||||
setReq := httptest.NewRequest(http.MethodPost, "/api/actions/set-welcome-message-templates", strings.NewReader(`{
|
||||
"reason": "test",
|
||||
"confirm": true,
|
||||
"phone_template": "custom phone template"
|
||||
}`))
|
||||
setRec := httptest.NewRecorder()
|
||||
srv.handleSetWelcomeMessageTemplatesAPI(setRec, setReq)
|
||||
if setRec.Code != http.StatusOK {
|
||||
t.Fatalf("SET status = %d, body = %s", setRec.Code, setRec.Body.String())
|
||||
}
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/server/identity", nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
srv.handleServerIdentityAPI(rec2, req2)
|
||||
var resp2 serverIdentityAPIResponse
|
||||
if err := json.Unmarshal(rec2.Body.Bytes(), &resp2); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp2.WelcomeMessagePhoneTemplate != "custom phone template" {
|
||||
t.Fatalf("expected phone override to be set, got %+v", resp2)
|
||||
}
|
||||
if resp2.WelcomeMessageEmailTemplate != "" {
|
||||
t.Fatalf("expected email override to stay unset, got %+v", resp2)
|
||||
}
|
||||
|
||||
// Reset (empty string) clears the override back to "unset".
|
||||
resetReq := httptest.NewRequest(http.MethodPost, "/api/actions/set-welcome-message-templates", strings.NewReader(`{
|
||||
"reason": "test",
|
||||
"confirm": true,
|
||||
"phone_template": ""
|
||||
}`))
|
||||
resetRec := httptest.NewRecorder()
|
||||
srv.handleSetWelcomeMessageTemplatesAPI(resetRec, resetReq)
|
||||
if resetRec.Code != http.StatusOK {
|
||||
t.Fatalf("reset status = %d, body = %s", resetRec.Code, resetRec.Body.String())
|
||||
}
|
||||
info, err := store.Get()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.WelcomeMessagePhoneTemplate != "" {
|
||||
t.Fatalf("expected phone override cleared after reset, got %+v", info)
|
||||
}
|
||||
}
|
||||
|
|
@ -26,28 +26,10 @@ type sessionClaims struct {
|
|||
Actor string `json:"actor"`
|
||||
Exp int64 `json:"exp"`
|
||||
Nonce string `json:"nonce"`
|
||||
// UserID identifies the admin_console_users row this session belongs to.
|
||||
//
|
||||
// Zero means the break-glass operator: whoever logged in with
|
||||
// TELESRV_ADMIN_UI_PASSWORD / _TOKEN rather than a named account. That
|
||||
// login has no database row, so it is deliberately exempt from the
|
||||
// per-request revocation check below -- it is the way back in when the
|
||||
// database is unreachable or every named account has been locked out.
|
||||
UserID int64 `json:"uid,omitempty"`
|
||||
// Epoch is the account's token_epoch at the moment this session was minted.
|
||||
//
|
||||
// Permissions travel inside the signed cookie, which is fast but means a
|
||||
// 12-hour session would otherwise keep whatever rights it was issued with
|
||||
// long after they were taken away. Every request re-reads the account's
|
||||
// current epoch and refuses the session if it has moved, so disabling an
|
||||
// operator, editing their rights or changing their password logs them out
|
||||
// on their very next request.
|
||||
Epoch int32 `json:"epoch,omitempty"`
|
||||
// Permissions is the right set granted to this session. For a named account
|
||||
// it is a snapshot of that row's permissions; for the break-glass operator
|
||||
// it comes from TELESRV_ADMIN_UI_PERMISSIONS. It cannot be edited by the
|
||||
// browser: the HMAC covers it. It is still re-read per request for named
|
||||
// accounts (see Epoch) so an edit narrows access immediately.
|
||||
// Permissions is the right set granted to this session, taken from
|
||||
// TELESRV_ADMIN_UI_PERMISSIONS at login. It travels inside the signed cookie
|
||||
// rather than being re-read per request, so a session keeps the rights it was
|
||||
// issued with, and it cannot be edited by the browser: the HMAC covers it.
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
// CSRF is the double-submit token bound to this session. Binding it into the
|
||||
// signed claims is what makes the cookie/header pair unforgeable by a sibling
|
||||
|
|
|
|||
|
|
@ -144,53 +144,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ func panelServer(t *testing.T, permissions ...string) *server {
|
|||
func signIn(t *testing.T, srv *server) ([]*http.Cookie, string) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"username":"owpengram","secret":"letmein"}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"secret":"letmein"}`))
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
|
||||
|
|
@ -94,7 +94,7 @@ func TestPanelSessionReportsPermissions(t *testing.T) {
|
|||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode session: %v", err)
|
||||
}
|
||||
if body.Actor != breakGlassUsername || len(body.Permissions) != 1 || body.Permissions[0] != permissionVerificationReview {
|
||||
if body.Actor != "admin" || len(body.Permissions) != 1 || body.Permissions[0] != permissionVerificationReview {
|
||||
t.Fatalf("session=%+v, want the granted permissions reported to the panel", body)
|
||||
}
|
||||
}
|
||||
|
|
@ -245,7 +245,7 @@ func originRequest(origin, host string) *http.Request {
|
|||
|
||||
func TestLoginRefusesAForeignOrigin(t *testing.T) {
|
||||
srv := panelServer(t, permissionAll)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"username":"owpengram","secret":"letmein"}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"secret":"letmein"}`))
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
|
|
|
|||
1
cmd/telesrv-admin/web/dist/assets/index-CQKJMNpu.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-CQKJMNpu.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
64
cmd/telesrv-admin/web/dist/index.html
vendored
64
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -1,32 +1,32 @@
|
|||
<!doctype html>
|
||||
<html lang="en" translate="no">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<title>OwpenGram Admin</title>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem("telesrv.admin.theme");
|
||||
var theme =
|
||||
stored === "light" || stored === "dark"
|
||||
? stored
|
||||
: window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-DTpNyCcP.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-P_k7ini0.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="en" translate="no">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<title>OwpenGram Admin</title>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem("telesrv.admin.theme");
|
||||
var theme =
|
||||
stored === "light" || stored === "dark"
|
||||
? stored
|
||||
: window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-fn4QJaPB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
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.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { api } from "./api";
|
||||
import { BootScreen, Shell } from "./components/Layout";
|
||||
import { SetupWizard } from "./components/SetupWizard";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { permissionAll, permissionServerManage, PermissionsProvider } from "./permissions";
|
||||
import { PermissionsProvider } from "./permissions";
|
||||
import { Routes } from "./pages/Routes";
|
||||
import { currentRoute, type RouteState } from "./routing";
|
||||
import type { AdminSession } from "./types";
|
||||
|
|
@ -38,39 +37,12 @@ export function App() {
|
|||
}
|
||||
|
||||
if (session === null) {
|
||||
return (
|
||||
<LoginPage
|
||||
onLogin={(next) => {
|
||||
// A stale/expired session can be caught on any deep link (a
|
||||
// bookmark, a page refresh mid-review), landing whoever it belongs
|
||||
// to on the login form without them having navigated there --
|
||||
// replaceState rather than a plain navigate() so signing back in
|
||||
// doesn't leave a "login" entry in browser history to land back on
|
||||
// via Back. Every login opens on the dashboard, not wherever the
|
||||
// expired session happened to be.
|
||||
window.history.replaceState(null, "", "/");
|
||||
setRoute(currentRoute());
|
||||
setSession(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The wizard only ever shows to whoever can actually act on it -- a
|
||||
// limited operator signing in before setup is finished just sees the
|
||||
// normal (mostly empty) shell instead of a wizard whose every step would
|
||||
// 403. setup_completed undefined (an admin binary old enough to predate
|
||||
// the field) reads as "done", same convention as the type's doc comment.
|
||||
const canRunSetupWizard = (session.permissions ?? []).some(
|
||||
(permission) => permission === permissionAll || permission === permissionServerManage
|
||||
);
|
||||
if (session.setup_completed === false && canRunSetupWizard) {
|
||||
return <SetupWizard />;
|
||||
return <LoginPage onLogin={setSession} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PermissionsProvider permissions={session.permissions ?? []} hideThirdPartyVerification={session.hide_third_party_verification ?? true}>
|
||||
<Shell actor={session.actor} apiLayers={session.api_layers} build={session.build} route={route} navigate={navigate} onLogout={() => setSession(null)}>
|
||||
<Shell actor={session.actor} route={route} navigate={navigate} onLogout={() => setSession(null)}>
|
||||
<Routes route={route} navigate={navigate} />
|
||||
</Shell>
|
||||
</PermissionsProvider>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import type {
|
||||
PublicBranding,
|
||||
AdminConsoleUserList,
|
||||
AccountDetail,
|
||||
AccountListResponse,
|
||||
AccountStatsResponse,
|
||||
|
|
@ -26,10 +24,6 @@ import type {
|
|||
CollectibleUsernameListResponse,
|
||||
ReservedUsernameListResponse,
|
||||
CommandResult,
|
||||
DockerService,
|
||||
EnvGroup,
|
||||
ServerIdentity,
|
||||
ServerStatus,
|
||||
GroupMessageDetail,
|
||||
GroupMessageListResponse,
|
||||
MessageDetail,
|
||||
|
|
@ -147,24 +141,16 @@ export function errorMessage(error: unknown): string {
|
|||
|
||||
export const api = {
|
||||
session: () => request<AdminSession>("/api/session"),
|
||||
// Reachable before login: the sign-in screen says which server it belongs to.
|
||||
publicBranding: () => request<PublicBranding>("/api/public/branding"),
|
||||
publicIconURL: () => `/api/public/icon?t=${Date.now()}`,
|
||||
// The built-in operator is named "owpengram" and is checked against the
|
||||
// configured TELESRV_ADMIN_UI_PASSWORD / _TOKEN -- the break-glass login
|
||||
// that still works when the database is unreachable. A blank username is
|
||||
// rejected: there is no anonymous way in.
|
||||
login: async (secret: string, username = "") => {
|
||||
login: async (secret: string) => {
|
||||
const result = await request<AdminLoginResult>("/api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, secret })
|
||||
body: JSON.stringify({ secret })
|
||||
});
|
||||
// Stashed here rather than in the caller so no login path can forget it.
|
||||
rememberCSRFToken(result.csrf_token);
|
||||
return result;
|
||||
},
|
||||
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
|
||||
adminUsers: () => request<AdminConsoleUserList>("/api/admin-users"),
|
||||
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
|
||||
accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"),
|
||||
sharedDeviceGroups: (params: URLSearchParams) => request<SharedDeviceGroupListResponse>(`/api/accounts/shared-devices?${params.toString()}`),
|
||||
|
|
@ -245,19 +231,10 @@ export const api = {
|
|||
gifCatalogDocumentPreviewURL: (documentID: string) => `/api/gif-catalog/documents/${encodeURIComponent(documentID)}/preview`,
|
||||
createStickerSet: (form: FormData) => request<CommandResult>("/api/actions/create-sticker-set", { method: "POST", body: form }),
|
||||
setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }),
|
||||
setAccountAvatarVideo: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar-video", { method: "POST", body: form }),
|
||||
setChannelAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-channel-avatar", { method: "POST", body: form }),
|
||||
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
|
||||
gifCatalog: () => request<GifCatalogListResponse>("/api/gif-catalog"),
|
||||
createGifCatalogEntry: (form: FormData) => request<CommandResult>("/api/actions/create-gif-catalog-entry", { method: "POST", body: form }),
|
||||
serverIdentity: () => request<ServerIdentity>("/api/server/identity"),
|
||||
addServerLink: () => request<{ link: string }>("/api/server/add-server-link"),
|
||||
uploadServerIcon: (form: FormData) => request<CommandResult>("/api/actions/upload-server-icon", { method: "POST", body: form }),
|
||||
serverIconURL: () => `/api/server/icon?t=${Date.now()}`,
|
||||
serverEnv: () => request<EnvGroup[]>("/api/server/env"),
|
||||
serverStatus: () => request<ServerStatus>("/api/server/status"),
|
||||
dockerStatus: () => request<DockerService[]>("/api/server/docker-status"),
|
||||
checkServerUpdates: () => request<{ commits_behind: number }>("/api/server/check-updates"),
|
||||
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
// navigator.clipboard only exists in a secure context (HTTPS, or localhost).
|
||||
// This admin panel is frequently reached over a plain http:// LAN address
|
||||
// (e.g. a self-hosted server's own IP), where navigator.clipboard is simply
|
||||
// undefined -- calling .writeText on it throws "Cannot read properties of
|
||||
// undefined". Fall back to the old execCommand('copy') path via a hidden,
|
||||
// off-screen textarea, which still works in that case.
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "-1000px";
|
||||
textarea.style.left = "-1000px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
try {
|
||||
const ok = document.execCommand("copy");
|
||||
if (!ok) {
|
||||
throw new Error("Copy command was not successful");
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,10 @@ import type { ReactNode } from "react";
|
|||
import { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { copyToClipboard } from "../clipboard";
|
||||
import type { CommandResult } from "../types";
|
||||
import { Alert, JsonBlock } from "./ui";
|
||||
|
||||
type ActionTone = "neutral" | "warn" | "danger" | "primary";
|
||||
type ActionTone = "neutral" | "warn" | "danger";
|
||||
|
||||
export function ActionButton({
|
||||
label,
|
||||
|
|
@ -78,7 +77,7 @@ export function ActionButton({
|
|||
}
|
||||
|
||||
const canConfirm = result?.dry_run && !result.error;
|
||||
const triggerClass = `btn ${tone === "danger" ? "danger" : tone === "warn" ? "warn" : tone === "primary" ? "primary" : ""} ${compact ? "compact-btn" : ""}`;
|
||||
const triggerClass = `btn ${tone === "danger" ? "danger" : tone === "warn" ? "warn" : ""} ${compact ? "compact-btn" : ""}`;
|
||||
const previewPayload = useMemo(() => {
|
||||
try {
|
||||
return payload();
|
||||
|
|
@ -95,7 +94,7 @@ export function ActionButton({
|
|||
: result?.details;
|
||||
|
||||
async function copySecret() {
|
||||
await copyToClipboard(secretValue);
|
||||
await navigator.clipboard.writeText(secretValue);
|
||||
setSecretCopied(true);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
import { Check, Copy, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { copyToClipboard } from "../clipboard";
|
||||
import { Alert, LoadingSurface } from "./ui";
|
||||
|
||||
// AddServerLinkModal shows a ready-made owpg://addserver link for this
|
||||
// exact server (host+port only -- see the Go handler's doc comment for why
|
||||
// name/description/key/DC are deliberately never embedded in it) -- an
|
||||
// operator hands this out (a website button, a QR code, a message
|
||||
// elsewhere) and the desktop/Android client's "Add Server" form opens
|
||||
// pre-filled from it, fetching the rest straight from this server itself.
|
||||
export function AddServerLinkModal({ onClose }: { onClose: () => void }) {
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.addServerLink()
|
||||
.then((result) => {
|
||||
if (!cancelled) setLink(result.link);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function copy() {
|
||||
if (!link) return;
|
||||
try {
|
||||
await copyToClipboard(link);
|
||||
setCopied(true);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal add-server-link-modal" role="dialog" aria-modal="true" aria-label={"Share server"}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{"Server"}</div>
|
||||
<h2>{"Share server"}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<p>{"Share this link (a button, a QR code, a message) so anyone with the OwpenGram client can add this server in one tap. It only carries the address and port -- the client fetches the name, description, and key directly from the server itself, so the link can never be tampered with to point someone at a fake identity for this address."}</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{!link && !error && <LoadingSurface label={"Building link..."} />}
|
||||
{link && (
|
||||
<div className="add-server-link-field">
|
||||
<label className="form-field">
|
||||
<span>{"owpg:// link"}</span>
|
||||
<textarea value={link} readOnly rows={2} onFocus={(event) => event.currentTarget.select()} />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="button" onClick={() => void copy()}>
|
||||
<Copy size={15} /> {copied ? "Copy again" : "Copy link"}
|
||||
</button>
|
||||
{copied && (
|
||||
<div className="secret-reveal">
|
||||
<div className="secret-reveal-label"><Check size={14} /> {"Copied to clipboard."}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={onClose}>{"Close"}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { BgIcons } from "./BgIcons";
|
||||
|
||||
// The product's background: three blurred orbs that drift with the pointer,
|
||||
// and the field of slowly moving icons over them. Ported from the marketing
|
||||
// site so the console reads as the same product.
|
||||
//
|
||||
// One component for both places it appears -- the sign-in screen and the
|
||||
// workspace behind every page -- because two copies of a parallax handler is
|
||||
// how they end up subtly different.
|
||||
export function AppBackground({ className = "" }: { className?: string }) {
|
||||
const orbsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The orbs drift with the pointer, the same 30px parallax the site uses.
|
||||
// Written straight to the node instead of through state: this fires on every
|
||||
// mouse move, and re-rendering the tree for a background offset would be a
|
||||
// lot of work to move three blurred circles.
|
||||
useEffect(() => {
|
||||
// Someone who asked the system for less motion gets none of this. The CSS
|
||||
// drift is already disabled for them, and a JS-driven transform would walk
|
||||
// straight past that preference.
|
||||
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
|
||||
return;
|
||||
}
|
||||
function onMove(event: MouseEvent) {
|
||||
const node = orbsRef.current;
|
||||
if (!node) return;
|
||||
const x = (event.clientX / window.innerWidth - 0.5) * 30;
|
||||
const y = (event.clientY / window.innerHeight - 0.5) * 30;
|
||||
node.style.transform = `translate(${x}px, ${y}px)`;
|
||||
}
|
||||
window.addEventListener("mousemove", onMove);
|
||||
return () => window.removeEventListener("mousemove", onMove);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`app-background ${className}`.trim()} aria-hidden="true">
|
||||
<div className="bg-orbs" ref={orbsRef}>
|
||||
<div className="bg-orb bg-orb--1" />
|
||||
<div className="bg-orb bg-orb--2" />
|
||||
<div className="bg-orb bg-orb--3" />
|
||||
</div>
|
||||
<BgIcons />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,20 +5,17 @@ export function AppLink({
|
|||
href,
|
||||
navigate,
|
||||
className,
|
||||
title,
|
||||
children
|
||||
}: {
|
||||
href: string;
|
||||
navigate: Navigate;
|
||||
className?: string;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
className={className}
|
||||
href={href}
|
||||
title={title}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigate(href);
|
||||
|
|
|
|||
|
|
@ -13,13 +13,10 @@ type AvatarModalKind = "user" | "channel";
|
|||
export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKind; id: number; onClose: () => void; onDone: () => void }) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewURL, setPreviewURL] = useState("");
|
||||
const [videoStartTs, setVideoStartTs] = useState("0");
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isVideo = kind === "user" && !!file && file.type.startsWith("video/");
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
setPreviewURL("");
|
||||
|
|
@ -32,7 +29,7 @@ export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKi
|
|||
|
||||
async function submit() {
|
||||
if (!file) {
|
||||
setError("Choose an image or video file first.");
|
||||
setError("Choose an image file first.");
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
|
|
@ -44,13 +41,9 @@ export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKi
|
|||
try {
|
||||
const idField = kind === "channel" ? "channel_id" : "user_id";
|
||||
const form = new FormData();
|
||||
const metadata: Record<string, unknown> = { command_id: "", reason: reason.trim(), confirm: true, [idField]: id };
|
||||
if (isVideo) {
|
||||
metadata.video_start_ts = Number(videoStartTs) || 0;
|
||||
}
|
||||
form.set("metadata", JSON.stringify(metadata));
|
||||
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, [idField]: id }));
|
||||
form.set("file", file, file.name);
|
||||
const result = kind === "channel" ? await api.setChannelAvatar(form) : isVideo ? await api.setAccountAvatarVideo(form) : await api.setAccountAvatar(form);
|
||||
const result = kind === "channel" ? await api.setChannelAvatar(form) : await api.setAccountAvatar(form);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
|
|
@ -78,32 +71,11 @@ export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKi
|
|||
</div>
|
||||
<div className="command-body">
|
||||
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
|
||||
<input
|
||||
type="file"
|
||||
accept={kind === "user" ? "image/png,image/jpeg,image/webp,video/mp4" : "image/png,image/jpeg,image/webp"}
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{previewURL ? (
|
||||
isVideo ? (
|
||||
<video className="gift-file-icon" src={previewURL} style={{ objectFit: "cover" }} muted loop autoPlay />
|
||||
) : (
|
||||
<img className="gift-file-icon" src={previewURL} alt="" style={{ objectFit: "cover" }} />
|
||||
)
|
||||
) : (
|
||||
<ImagePlus size={22} />
|
||||
)}
|
||||
<span className="gift-file-copy">
|
||||
<span className="gift-field-label">{"New avatar"}</span>
|
||||
<strong>{file ? file.name : kind === "user" ? "Choose a JPEG, PNG, WebP image, or MP4 video" : "Choose a JPEG, PNG, or WebP image"}</strong>
|
||||
</span>
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||
{previewURL ? <img className="gift-file-icon" src={previewURL} alt="" style={{ objectFit: "cover" }} /> : <ImagePlus size={22} />}
|
||||
<span className="gift-file-copy"><span className="gift-field-label">{"New avatar"}</span><strong>{file ? file.name : "Choose a JPEG, PNG, or WebP image"}</strong></span>
|
||||
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
|
||||
</label>
|
||||
{isVideo && (
|
||||
<label className="gift-reason-field">
|
||||
<span>{"Video start (seconds)"}</span>
|
||||
<input type="number" min="0" step="0.1" value={videoStartTs} onChange={(event) => setVideoStartTs(event.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this avatar is being changed"} onChange={(event) => setReason(event.target.value)} /></label>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
// The drifting icon field from the marketing site (owpengram-site's
|
||||
// BgIcons.vue), ported so the console's sign-in screen looks like the same
|
||||
// product rather than a different one.
|
||||
//
|
||||
// Same 28 particles, same sizes, speeds, opacities and repulsion as the
|
||||
// original -- this is meant to match, not to be a second interpretation of the
|
||||
// idea.
|
||||
|
||||
// Inner markup for each icon, drawn as strokes on a 24x24 viewBox. These are
|
||||
// source constants, never anything a user supplied, which is what makes
|
||||
// injecting them as HTML below safe.
|
||||
const ICON_PATHS = [
|
||||
`<rect x="2.5" y="3.5" width="19" height="12" rx="2"/><path d="M8 20h8M12 15.5V20"/>`,
|
||||
`<rect x="3.5" y="4" width="17" height="6" rx="2"/><rect x="3.5" y="14" width="17" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>`,
|
||||
`<rect x="3" y="5" width="18" height="14" rx="2.5"/><path d="M3 7l9 6.5L21 7"/>`,
|
||||
`<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3c2.6 2.5 4 5.7 4 9s-1.4 6.5-4 9c-2.6-2.5-4-5.7-4-9s1.4-6.5 4-9z"/>`,
|
||||
`<rect x="7" y="3" width="10" height="18" rx="2.4"/><path d="M11 18h2"/>`,
|
||||
`<path d="M8 6l-6 6 6 6M16 6l6 6-6 6"/>`,
|
||||
`<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M18.5 15.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z"/>`,
|
||||
`<path d="M12 3l7 3v5c0 4.5-3 7.6-7 9-4-1.4-7-4.5-7-9V6l7-3z"/><path d="M8.8 12.2l2.1 2.1 4.3-4.3"/>`,
|
||||
`<path d="M21.5 3.5l-19 7.5 5.5 2.5 3 5 3-2 5.5 5z"/><path d="M11 14l8.5-8.5"/>`,
|
||||
`<rect x="4.5" y="11" width="15" height="9" rx="2.2"/><path d="M8 11V8a4 4 0 0 1 7.5-1.9"/><path d="M12 15v2"/>`,
|
||||
`<path d="M12 3v12"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/>`,
|
||||
`<circle cx="12" cy="12" r="2.5"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8"/>`,
|
||||
`<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>`,
|
||||
`<path d="M12 2l3.1 6.3L22 9.5l-5 4.9 1.2 7L12 17.3 5.8 21.4 7 14.4l-5-4.9 6.9-1.2z"/>`,
|
||||
`<path d="M14.5 17.5L19 13l-4.5-4.5M9.5 6.5L5 11l4.5 4.5"/>`,
|
||||
`<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>`,
|
||||
`<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/><path d="M15 12h4M17 10v4"/>`,
|
||||
`<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>`,
|
||||
`<circle cx="12" cy="12" r="2"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>`,
|
||||
`<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>`,
|
||||
`<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/>`,
|
||||
`<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/>`,
|
||||
`<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>`,
|
||||
`<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/>`,
|
||||
`<circle cx="12" cy="12" r="10"/><path d="M12 6v12M6 12h12"/>`
|
||||
];
|
||||
|
||||
const PARTICLE_COUNT = 28;
|
||||
|
||||
type Particle = {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
rot: number;
|
||||
rotSpeed: number;
|
||||
};
|
||||
|
||||
export function BgIcons() {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
// Someone who asked the system for less motion gets a still field rather
|
||||
// than twenty-eight things drifting across their screen.
|
||||
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = Array.from(host.querySelectorAll<SVGSVGElement>(".bg-icon"));
|
||||
let particles: Particle[] = [];
|
||||
|
||||
function seed() {
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
const margin = 60;
|
||||
particles = nodes.map((node) => {
|
||||
const size = 22 + Math.random() * 30;
|
||||
node.style.width = `${size}px`;
|
||||
node.style.height = `${size}px`;
|
||||
node.style.opacity = String(0.18 + Math.random() * 0.12);
|
||||
return {
|
||||
x: margin + Math.random() * Math.max(1, w - margin * 2),
|
||||
y: margin + Math.random() * Math.max(1, h - margin * 2),
|
||||
vx: (Math.random() - 0.5) * 0.3,
|
||||
vy: (Math.random() - 0.5) * 0.3,
|
||||
size,
|
||||
rot: Math.random() * 360,
|
||||
rotSpeed: (Math.random() - 0.5) * 0.08
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
function tick() {
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const a = particles[i];
|
||||
a.x += a.vx;
|
||||
a.y += a.vy;
|
||||
a.rot += a.rotSpeed;
|
||||
|
||||
// Bounce off the viewport edges so the field stays on screen.
|
||||
const half = a.size / 2;
|
||||
if (a.x < half) { a.x = half; a.vx *= -1; }
|
||||
if (a.x > w - half) { a.x = w - half; a.vx *= -1; }
|
||||
if (a.y < half) { a.y = half; a.vy *= -1; }
|
||||
if (a.y > h - half) { a.y = h - half; a.vy *= -1; }
|
||||
|
||||
// Gentle mutual repulsion: without it the drift eventually clumps the
|
||||
// icons into a corner and the field stops reading as a field.
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const b = particles[j];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const minDist = (a.size + b.size) / 2 + 20;
|
||||
if (dist < minDist && dist > 0.01) {
|
||||
const force = ((minDist - dist) / minDist) * 0.02;
|
||||
const nx = dx / dist;
|
||||
const ny = dy / dist;
|
||||
a.vx -= nx * force;
|
||||
a.vy -= ny * force;
|
||||
b.vx += nx * force;
|
||||
b.vy += ny * force;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Written straight to the nodes: this runs every frame, and putting 28
|
||||
// positions through React state would re-render the sign-in form sixty
|
||||
// times a second to move some background art.
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const p = particles[i];
|
||||
nodes[i].style.transform = `translate(${p.x}px, ${p.y}px) rotate(${p.rot}deg)`;
|
||||
}
|
||||
frame = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
seed();
|
||||
frame = requestAnimationFrame(tick);
|
||||
window.addEventListener("resize", seed);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("resize", seed);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-icons" aria-hidden="true" ref={hostRef}>
|
||||
{Array.from({ length: PARTICLE_COUNT }, (_, i) => (
|
||||
<svg
|
||||
key={i}
|
||||
className="bg-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
dangerouslySetInnerHTML={{ __html: ICON_PATHS[i % ICON_PATHS.length] }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ import { Check, Copy, X } from "lucide-react";
|
|||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { copyToClipboard } from "../clipboard";
|
||||
import { Alert } from "./ui";
|
||||
|
||||
// CopyBotTokenModal writes a non-system bot's token straight to the
|
||||
|
|
@ -35,7 +34,7 @@ export function CopyBotTokenModal({ botID, onClose }: { botID: number; onClose:
|
|||
setError(result.error || "No token returned.");
|
||||
return;
|
||||
}
|
||||
await copyToClipboard(token);
|
||||
await navigator.clipboard.writeText(token);
|
||||
setCopied(true);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
|
|
|
|||
|
|
@ -8,11 +8,9 @@ import { MultiUserPicker } from "./EntityPicker";
|
|||
type TargetMode = "all" | "selected";
|
||||
|
||||
// CreateBroadcastModal composes the message and target list, then hands off to
|
||||
// ActionButton for the usual dry-run/confirm flow. "All users" is never
|
||||
// resolved into an id list at all -- the admin service snapshots the
|
||||
// current eligible user set itself and a background worker enumerates it
|
||||
// incrementally, so this only ever sends user_ids for "selected" mode,
|
||||
// where the picker deals with an actual, visible list of accounts.
|
||||
// ActionButton for the usual dry-run/confirm flow. "All users" is resolved to an
|
||||
// explicit id list server-side (cmd/telesrv-admin/server.go), not here -- the
|
||||
// picker only ever deals with an actual, visible list of accounts.
|
||||
export function CreateBroadcastModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [targetMode, setTargetMode] = useState<TargetMode>("all");
|
||||
|
|
|
|||
|
|
@ -10,62 +10,20 @@ import {
|
|||
LogOut,
|
||||
Megaphone,
|
||||
MessageSquareText,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Settings,
|
||||
UserCog,
|
||||
UserRound,
|
||||
Share2,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Smile,
|
||||
Stamp,
|
||||
Users,
|
||||
Zap,
|
||||
Sticker
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { clearAdminCache } from "../lib/cache";
|
||||
import { permissionBotVerificationReview, permissionServerManage, permissionAdminsManage,
|
||||
permissionAccountsRead,
|
||||
permissionChannelsRead,
|
||||
permissionBotsRead,
|
||||
permissionMessagesRead,
|
||||
permissionModerationReview,
|
||||
permissionBroadcastsRead,
|
||||
permissionStorageRead,
|
||||
permissionContentRead,
|
||||
permissionUsernamesRead,
|
||||
permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
|
||||
import { api } from "../api";
|
||||
import { permissionBotVerificationReview, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
|
||||
import { type Navigate, type RouteState, routeTitle } from "../routing";
|
||||
import { ThemeSwitch } from "../theme";
|
||||
import { AddServerLinkModal } from "./AddServerLinkModal";
|
||||
import { AppBackground } from "./AppBackground";
|
||||
import { AppLink } from "./AppLink";
|
||||
|
||||
// Compresses a sorted (or unsorted) list of layer numbers into run-length
|
||||
// ranges for the compact sidebar label, e.g. [225,226,227,228,229] -> "225-229",
|
||||
// or [225,226,228] -> "225-226, 228" if the server ever supports a
|
||||
// non-contiguous set. The full list is still always shown in the tooltip.
|
||||
function formatLayerRanges(layers: number[]): string {
|
||||
const sorted = [...layers].sort((a, b) => a - b);
|
||||
const parts: string[] = [];
|
||||
let start = sorted[0];
|
||||
let prev = sorted[0];
|
||||
for (let i = 1; i <= sorted.length; i++) {
|
||||
const current = sorted[i];
|
||||
if (current === prev + 1) {
|
||||
prev = current;
|
||||
continue;
|
||||
}
|
||||
parts.push(start === prev ? `${start}` : `${start}-${prev}`);
|
||||
start = current;
|
||||
prev = current;
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
export function BootScreen() {
|
||||
return (
|
||||
<div className="boot-screen">
|
||||
|
|
@ -83,16 +41,12 @@ export function BootScreen() {
|
|||
|
||||
export function Shell({
|
||||
actor,
|
||||
apiLayers,
|
||||
build,
|
||||
route,
|
||||
navigate,
|
||||
onLogout,
|
||||
children
|
||||
}: {
|
||||
actor: string;
|
||||
apiLayers?: number[];
|
||||
build?: { commit: string; short_commit: string; dirty: boolean; build_time: string };
|
||||
route: RouteState;
|
||||
navigate: Navigate;
|
||||
onLogout: () => void;
|
||||
|
|
@ -101,282 +55,98 @@ export function Shell({
|
|||
// The verification queue is hidden for a session without verification.review:
|
||||
// the entry would only lead to a 403 (and the route itself is gated as well).
|
||||
const canReviewVerification = useCan(permissionVerificationReview);
|
||||
const canManageAdmins = useCan(permissionAdminsManage);
|
||||
// Each section entry is hidden without the right to open it: the route is
|
||||
// gated server-side either way, so showing it would only lead to a 403.
|
||||
const canReadAccounts = useCan(permissionAccountsRead);
|
||||
const canReadChannels = useCan(permissionChannelsRead);
|
||||
const canReadBots = useCan(permissionBotsRead);
|
||||
const canReadMessages = useCan(permissionMessagesRead);
|
||||
const canReviewModeration = useCan(permissionModerationReview);
|
||||
const canReadBroadcasts = useCan(permissionBroadcastsRead);
|
||||
const canReadStorage = useCan(permissionStorageRead);
|
||||
const canReadContent = useCan(permissionContentRead);
|
||||
const canReadUsernames = useCan(permissionUsernamesRead);
|
||||
// Same reasoning for the third-party queue, which has its own right: the two
|
||||
// sections are granted independently, so one entry can be visible without the other.
|
||||
const canReviewBotVerification = useCan(permissionBotVerificationReview);
|
||||
const canManageServer = useCan(permissionServerManage);
|
||||
// Remembered per browser: an operator who works in a narrow window should not
|
||||
// have to re-collapse the navigation on every visit. A failed read (private
|
||||
// mode, blocked storage) just means the default.
|
||||
const [navCollapsed, setNavCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem("owpengram.nav.collapsed") === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
function toggleNav() {
|
||||
setNavCollapsed((current) => {
|
||||
const next = !current;
|
||||
try {
|
||||
localStorage.setItem("owpengram.nav.collapsed", next ? "1" : "0");
|
||||
} catch {
|
||||
// Not being able to remember the choice is not a reason to refuse it.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const [addServerLinkOpen, setAddServerLinkOpen] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [connectError, setConnectError] = useState("");
|
||||
|
||||
// "Connect" is the short-cut version of Share: instead of showing a link to
|
||||
// copy elsewhere, it opens the owpg://addserver link (host+port only --
|
||||
// see the Go handler's doc comment for why nothing else ever goes in it)
|
||||
// right here in this browser, so if an OwpenGram client is registered for
|
||||
// that scheme on this machine, it launches straight into "Add Server"
|
||||
// pre-filled for the server this very admin panel manages, fetching the
|
||||
// rest (name/description/key) straight from it.
|
||||
async function connectThisServer() {
|
||||
setConnecting(true);
|
||||
setConnectError("");
|
||||
try {
|
||||
const result = await api.addServerLink();
|
||||
window.location.href = result.link;
|
||||
} catch (err) {
|
||||
setConnectError(errorMessage(err));
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
}
|
||||
// Server identity (name/icon) is admin-editable per Server Settings ->
|
||||
// Server identity, and takes over the sidebar branding when set -- the
|
||||
// operator's own server should look like their server, not like the
|
||||
// "OwpenGram" reference build, once they've bothered to configure it.
|
||||
// Only fetched for sessions that can even see Server Settings; a session
|
||||
// without that permission just gets the default branding.
|
||||
const [identity, setIdentity] = useState<{ name: string; iconExt?: string } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!canManageServer) return;
|
||||
api.serverIdentity()
|
||||
.then((info) => setIdentity({ name: info.name, iconExt: info.icon_ext }))
|
||||
.catch(() => undefined);
|
||||
}, [canManageServer]);
|
||||
const [brandIconFailed, setBrandIconFailed] = useState(false);
|
||||
const brandName = identity?.name?.trim() || "OwpenGram";
|
||||
const brandIconSrc = identity?.iconExt && !brandIconFailed ? api.serverIconURL() : "/logo.png";
|
||||
|
||||
// The browser tab (title + favicon) follows the same custom-identity
|
||||
// override as the sidebar brand above, so a re-labeled server actually
|
||||
// looks like itself in the tab strip too, not just inside the app.
|
||||
useEffect(() => {
|
||||
document.title = `${brandName} Admin`;
|
||||
}, [brandName]);
|
||||
useEffect(() => {
|
||||
let link = document.querySelector<HTMLLinkElement>("link[rel='icon']");
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.rel = "icon";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
const linkEl = link;
|
||||
const src = identity?.iconExt && !brandIconFailed ? api.serverIconURL() : "/logo.png";
|
||||
// Browsers render the favicon file as-is -- they don't apply the
|
||||
// sidebar's CSS border-radius to it, so a square-cornered source image
|
||||
// (the default logo, or whatever shape an operator's uploaded icon
|
||||
// happens to be) shows up square in the tab strip. Bake the circular
|
||||
// mask into the actual pixels instead, the same way an app icon export
|
||||
// would, so the tab matches the round mark everywhere else in the UI.
|
||||
let cancelled = false;
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const size = 64;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
linkEl.href = src;
|
||||
return;
|
||||
}
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
|
||||
ctx.closePath();
|
||||
ctx.clip();
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
ctx.restore();
|
||||
linkEl.href = canvas.toDataURL("image/png");
|
||||
};
|
||||
img.onerror = () => {
|
||||
// Cross-origin or load failure: fall back to the raw image rather
|
||||
// than leaving the tab with no favicon at all.
|
||||
if (!cancelled) {
|
||||
linkEl.href = src;
|
||||
}
|
||||
};
|
||||
img.src = src;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity?.iconExt, brandIconFailed]);
|
||||
// Third-party verification is additionally hidden by default (not fully
|
||||
// finished) regardless of what the session was granted -- see permissions.tsx.
|
||||
const thirdPartyVerificationHidden = useThirdPartyVerificationHidden();
|
||||
const messagesActive = route.path.startsWith("/messages");
|
||||
const [messagesOpen, setMessagesOpen] = useState(messagesActive);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesActive) {
|
||||
setMessagesOpen(true);
|
||||
}
|
||||
}, [messagesActive]);
|
||||
|
||||
async function logout() {
|
||||
await api.logout().catch(() => undefined);
|
||||
// Drop the cached figures with the session. Without this the next operator
|
||||
// to sign in on this tab would open the dashboard on the previous one's
|
||||
// numbers -- briefly, but from an account that may not be allowed to see
|
||||
// them at all.
|
||||
clearAdminCache();
|
||||
onLogout();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`shell ${navCollapsed ? "shell--nav-collapsed" : ""}`.trim()}>
|
||||
<div className="shell">
|
||||
<aside className="sidebar">
|
||||
<AppLink className="brand" href="/" navigate={navigate}>
|
||||
<span className="brand-mark"><img src={brandIconSrc} alt={brandName} onError={() => setBrandIconFailed(true)} /></span>
|
||||
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
|
||||
<span>
|
||||
<strong>{brandName}</strong>
|
||||
<strong>OwpenGram</strong>
|
||||
<small>{"Admin Console"}</small>
|
||||
</span>
|
||||
</AppLink>
|
||||
<div className="sidebar-label">{"Navigation"}</div>
|
||||
<nav className="nav-list" aria-label={"Primary navigation"}>
|
||||
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{"Overview"}</NavLink>
|
||||
{canReadAccounts && (
|
||||
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{"Accounts"}</NavLink>
|
||||
)}
|
||||
{canReadChannels && (
|
||||
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{"Supergroups / Channels"}</NavLink>
|
||||
)}
|
||||
{canReadBots && (
|
||||
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{"Bots"}</NavLink>
|
||||
)}
|
||||
{canReviewModeration && (
|
||||
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{"Reports / Moderation"}</NavLink>
|
||||
)}
|
||||
{canReadBroadcasts && (
|
||||
<NavLink icon={<Megaphone size={16} />} href="/broadcasts" route={route} navigate={navigate}>{"Broadcasts"}</NavLink>
|
||||
)}
|
||||
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{"Accounts"}</NavLink>
|
||||
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{"Supergroups / Channels"}</NavLink>
|
||||
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{"Bots"}</NavLink>
|
||||
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{"Reports / Moderation"}</NavLink>
|
||||
<NavLink icon={<Megaphone size={16} />} href="/broadcasts" route={route} navigate={navigate}>{"Broadcasts"}</NavLink>
|
||||
{canReviewVerification && (
|
||||
<NavLink icon={<BadgeCheck size={16} />} href="/verification" route={route} navigate={navigate}>{"Verification"}</NavLink>
|
||||
)}
|
||||
{canReviewBotVerification && !thirdPartyVerificationHidden && (
|
||||
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{"Third-party marks"}</NavLink>
|
||||
)}
|
||||
{canReadUsernames && (
|
||||
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
|
||||
)}
|
||||
{canReadUsernames && (
|
||||
<NavLink icon={<Ban size={16} />} href="/reserved-usernames" route={route} navigate={navigate}>{"Reserved Usernames"}</NavLink>
|
||||
)}
|
||||
{canReadStorage && (
|
||||
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
|
||||
)}
|
||||
{canReadContent && (
|
||||
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
|
||||
)}
|
||||
{canReadContent && (
|
||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
|
||||
)}
|
||||
{canReadContent && (
|
||||
<NavLink icon={<Film size={16} />} href="/gif-catalog" route={route} navigate={navigate}>{"GIFs"}</NavLink>
|
||||
)}
|
||||
{canReadMessages && (
|
||||
<NavLink
|
||||
icon={<MessageSquareText size={16} />}
|
||||
href="/messages/private"
|
||||
route={route}
|
||||
navigate={navigate}
|
||||
activeWhen={(path) => path.startsWith("/messages")}
|
||||
>
|
||||
{"Messages"}
|
||||
</NavLink>
|
||||
)}
|
||||
{canManageAdmins && (
|
||||
<NavLink icon={<UserCog size={16} />} href="/admin-users" route={route} navigate={navigate}>{"Operators"}</NavLink>
|
||||
)}
|
||||
{canManageServer && (
|
||||
<NavLink icon={<Settings size={16} />} href="/server-settings" route={route} navigate={navigate}>{"Server Settings"}</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
<div className="sidebar-status">
|
||||
<span className="sidebar-label">{"Version: O7"}</span>
|
||||
{apiLayers && apiLayers.length > 0 && (
|
||||
<span className="sidebar-label sidebar-api-layer" title={`Layers: ${apiLayers.join(", ")}`}>
|
||||
{`API layers: ${formatLayerRanges(apiLayers)}`}
|
||||
</span>
|
||||
)}
|
||||
{build?.short_commit && (
|
||||
<span className="sidebar-label sidebar-build" title={build.commit + (build.dirty ? " (uncommitted changes)" : "")}>
|
||||
{`Build: ${build.short_commit}${build.dirty ? "+" : ""}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canManageServer && (
|
||||
<div className="sidebar-server-actions">
|
||||
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
|
||||
<NavLink icon={<Ban size={16} />} href="/reserved-usernames" route={route} navigate={navigate}>{"Reserved Usernames"}</NavLink>
|
||||
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
|
||||
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
|
||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
|
||||
<NavLink icon={<Film size={16} />} href="/gif-catalog" route={route} navigate={navigate}>{"GIFs"}</NavLink>
|
||||
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
|
||||
<button
|
||||
className="btn ghost sidebar-server-action"
|
||||
className="nav-section-toggle"
|
||||
type="button"
|
||||
title={"Connect this browser's client to this server"}
|
||||
disabled={connecting}
|
||||
onClick={() => void connectThisServer()}
|
||||
aria-expanded={messagesOpen}
|
||||
onClick={() => setMessagesOpen((open) => !open)}
|
||||
>
|
||||
<Zap size={15} /> {"Connect"}
|
||||
</button>
|
||||
<button
|
||||
className="btn ghost sidebar-server-action"
|
||||
type="button"
|
||||
title={"Share server (get an add-server link)"}
|
||||
onClick={() => setAddServerLinkOpen(true)}
|
||||
>
|
||||
<Share2 size={15} /> {"Share"}
|
||||
<MessageSquareText size={16} />
|
||||
<span>{"Messages"}</span>
|
||||
<ChevronDown className="nav-section-chevron" size={15} />
|
||||
</button>
|
||||
{messagesOpen && (
|
||||
<div className="nav-children">
|
||||
<NavLink
|
||||
href="/messages/private"
|
||||
route={route}
|
||||
navigate={navigate}
|
||||
activeWhen={(path) => path === "/messages" || path === "/messages/detail" || path.startsWith("/messages/private")}
|
||||
>
|
||||
{"Private"}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
href="/messages/groups"
|
||||
route={route}
|
||||
navigate={navigate}
|
||||
activeWhen={(path) => path.startsWith("/messages/groups")}
|
||||
>
|
||||
{"Groups"}
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{connectError && <div className="sidebar-server-action-error">{connectError}</div>}
|
||||
{addServerLinkOpen && <AddServerLinkModal onClose={() => setAddServerLinkOpen(false)} />}
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="workspace">
|
||||
<header className="topbar">
|
||||
<div className="topbar-lead">
|
||||
<button
|
||||
className="icon-btn nav-toggle"
|
||||
type="button"
|
||||
onClick={toggleNav}
|
||||
aria-expanded={!navCollapsed}
|
||||
aria-label={navCollapsed ? "Expand navigation" : "Collapse navigation"}
|
||||
title={navCollapsed ? "Expand navigation" : "Collapse navigation"}
|
||||
>
|
||||
{navCollapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
|
||||
</button>
|
||||
<div>
|
||||
<h1>{routeTitle(route.path)}</h1>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<ThemeSwitch />
|
||||
<span className="actor-pill"><UserRound size={14} /> {actor}</span>
|
||||
<span className="actor-pill">{`Actor: ${actor}`}</span>
|
||||
<button className="btn ghost icon-text" type="button" onClick={logout} title={"Log out"}>
|
||||
<LogOut size={16} /> {"Log out"}
|
||||
</button>
|
||||
|
|
@ -384,9 +154,6 @@ export function Shell({
|
|||
</header>
|
||||
<main className="content">{children}</main>
|
||||
</div>
|
||||
{/* Behind everything, fixed to the viewport. The sidebar and topbar paint
|
||||
over it, so it shows through the working area only. */}
|
||||
<AppBackground className="app-background--workspace" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -408,16 +175,9 @@ function NavLink({
|
|||
}) {
|
||||
const active = activeWhen ? activeWhen(route.path) : href === "/" ? route.path === "/" : route.path.startsWith(href);
|
||||
return (
|
||||
<AppLink
|
||||
className={`nav-item ${active ? "active" : ""}`}
|
||||
href={href}
|
||||
navigate={navigate}
|
||||
// The label is hidden when the sidebar is collapsed, so it moves to the
|
||||
// tooltip -- an icon rail with no names is a memory test.
|
||||
title={typeof children === "string" ? children : undefined}
|
||||
>
|
||||
<AppLink className={`nav-item ${active ? "active" : ""}`} href={href} navigate={navigate}>
|
||||
{icon ?? <span aria-hidden="true" className="nav-dot" />}
|
||||
<span className="nav-item-label">{children}</span>
|
||||
<span>{children}</span>
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,163 +0,0 @@
|
|||
import {
|
||||
Contact,
|
||||
Dice5,
|
||||
FileText,
|
||||
Gift,
|
||||
Image,
|
||||
Link2,
|
||||
ListChecks,
|
||||
MapPin,
|
||||
Radio,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { formatBytes } from "../lib/format";
|
||||
|
||||
// What a message actually was, rendered the way a person reads it: the text
|
||||
// first, then what was attached to it. The database rows behind it are still
|
||||
// available further down each detail page, but an operator opening a message
|
||||
// is nearly always asking "what does it say", and answering that with a JSON
|
||||
// dump made them decode the answer themselves.
|
||||
|
||||
// Mirrors domain.MessageMedia's JSON. Everything is optional because the
|
||||
// snapshot is written by a server that keeps gaining media kinds -- an unknown
|
||||
// one has to degrade to "there is media of kind X" rather than blow up.
|
||||
type MediaSnapshot = {
|
||||
kind?: string;
|
||||
document?: { file_name?: string; mime_type?: string; size?: number; duration?: number };
|
||||
photo?: { id?: number | string };
|
||||
contact?: { first_name?: string; last_name?: string; phone_number?: string };
|
||||
geo?: { lat?: number; long?: number };
|
||||
geo_live?: { lat?: number; long?: number; period?: number };
|
||||
venue?: { title?: string; address?: string };
|
||||
poll?: { question?: string; answers?: unknown[]; closed?: boolean };
|
||||
web_page?: { url?: string; title?: string; site_name?: string };
|
||||
story?: { id?: number };
|
||||
todo?: { title?: string };
|
||||
dice?: { emoticon?: string; value?: number };
|
||||
giveaway?: unknown;
|
||||
service_action?: { kind?: string; type?: string };
|
||||
spoiler?: boolean;
|
||||
ttl_seconds?: number;
|
||||
voice?: boolean;
|
||||
round?: boolean;
|
||||
video?: boolean;
|
||||
};
|
||||
|
||||
function parseMedia(raw: string | undefined): MediaSnapshot | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as MediaSnapshot;
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
// "{}" is what a text-only message stores, not a media object.
|
||||
if (Object.keys(parsed).length === 0) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// describeMedia turns the snapshot into a line a person can read plus the icon
|
||||
// that goes with it. Unknown kinds still get a row, named after the kind.
|
||||
function describeMedia(media: MediaSnapshot): { icon: LucideIcon; title: string; detail: string } {
|
||||
const kind = media.kind ?? "";
|
||||
switch (kind) {
|
||||
case "photo":
|
||||
return { icon: Image, title: "Photo", detail: media.photo?.id ? `id ${media.photo.id}` : "" };
|
||||
case "document": {
|
||||
const doc = media.document ?? {};
|
||||
const bits = [doc.mime_type, doc.size ? formatBytes(String(doc.size)) : "", doc.duration ? `${doc.duration}s` : ""].filter(Boolean);
|
||||
const title = media.voice ? "Voice message" : media.round ? "Round video" : media.video ? "Video" : "File";
|
||||
return { icon: FileText, title, detail: [doc.file_name, bits.join(" · ")].filter(Boolean).join(" — ") };
|
||||
}
|
||||
case "contact": {
|
||||
const c = media.contact ?? {};
|
||||
const name = [c.first_name, c.last_name].filter(Boolean).join(" ");
|
||||
return { icon: Contact, title: "Contact", detail: [name, c.phone_number].filter(Boolean).join(" · ") };
|
||||
}
|
||||
case "geo":
|
||||
return { icon: MapPin, title: "Location", detail: media.geo ? `${media.geo.lat}, ${media.geo.long}` : "" };
|
||||
case "geo_live":
|
||||
return { icon: MapPin, title: "Live location", detail: media.geo_live ? `${media.geo_live.lat}, ${media.geo_live.long}` : "" };
|
||||
case "venue":
|
||||
return { icon: MapPin, title: "Venue", detail: [media.venue?.title, media.venue?.address].filter(Boolean).join(" — ") };
|
||||
case "poll":
|
||||
return {
|
||||
icon: ListChecks,
|
||||
title: media.poll?.closed ? "Poll (closed)" : "Poll",
|
||||
detail: [media.poll?.question, media.poll?.answers ? `${media.poll.answers.length} options` : ""].filter(Boolean).join(" — ")
|
||||
};
|
||||
case "web_page":
|
||||
return { icon: Link2, title: "Link preview", detail: [media.web_page?.title, media.web_page?.url].filter(Boolean).join(" — ") };
|
||||
case "story":
|
||||
return { icon: Sparkles, title: "Story", detail: media.story?.id ? `id ${media.story.id}` : "" };
|
||||
case "todo":
|
||||
return { icon: ListChecks, title: "Checklist", detail: media.todo?.title ?? "" };
|
||||
case "dice":
|
||||
return { icon: Dice5, title: "Dice", detail: [media.dice?.emoticon, media.dice?.value].filter(Boolean).join(" ") };
|
||||
case "giveaway":
|
||||
return { icon: Gift, title: "Giveaway", detail: "" };
|
||||
case "service":
|
||||
return { icon: Settings2, title: "Service action", detail: media.service_action?.kind ?? media.service_action?.type ?? "" };
|
||||
default:
|
||||
return { icon: Radio, title: kind ? `Media (${kind})` : "Media", detail: "" };
|
||||
}
|
||||
}
|
||||
|
||||
export function MessageView({
|
||||
body,
|
||||
media,
|
||||
sender,
|
||||
meta,
|
||||
badges
|
||||
}: {
|
||||
body: string;
|
||||
media?: string;
|
||||
// Who sent it, already resolved to something readable by the caller.
|
||||
sender: string;
|
||||
// When, and anything else that belongs on the header line.
|
||||
meta: string;
|
||||
badges?: ReactNode;
|
||||
}) {
|
||||
const parsed = parseMedia(media);
|
||||
const described = parsed ? describeMedia(parsed) : null;
|
||||
const Icon = described?.icon;
|
||||
const text = body?.trim() ?? "";
|
||||
|
||||
return (
|
||||
<section className="message-view">
|
||||
<div className="message-view-head">
|
||||
<div>
|
||||
<strong>{sender}</strong>
|
||||
<small>{meta}</small>
|
||||
</div>
|
||||
{badges && <div className="entity-badges">{badges}</div>}
|
||||
</div>
|
||||
|
||||
<div className="message-bubble">
|
||||
{text
|
||||
? <p className="message-text">{text}</p>
|
||||
: <p className="message-text empty">{described ? "No caption" : "No text"}</p>}
|
||||
|
||||
{described && Icon && (
|
||||
<div className="message-attachment">
|
||||
<span className="message-attachment-icon"><Icon size={16} /></span>
|
||||
<span className="message-attachment-copy">
|
||||
<strong>{described.title}</strong>
|
||||
{described.detail && <small>{described.detail}</small>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsed && (parsed.spoiler || parsed.ttl_seconds) && (
|
||||
<div className="message-flags">
|
||||
{parsed.spoiler && <span className="chip">{"Spoiler"}</span>}
|
||||
{parsed.ttl_seconds ? <span className="chip">{`Self-destructs after ${parsed.ttl_seconds}s`}</span> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,513 +0,0 @@
|
|||
import { ArrowRight, Check, ImagePlus, Loader2, Rocket, UserPlus } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { RestartOverlay, ServerIconModal, useAdminRestartWatcher } from "../pages/ServerSettingsPage";
|
||||
import { ThemeSwitch } from "../theme";
|
||||
import { AppBackground } from "./AppBackground";
|
||||
import { Alert } from "./ui";
|
||||
|
||||
// The first-run wizard: shown instead of the normal shell exactly once, when
|
||||
// GET /api/session answers setup_completed=false (see
|
||||
// identity.Store.SetupPending and cmd/telesrv-admin/server.go's
|
||||
// handleSession). It covers the same ground
|
||||
// tui-panel/server-panel.py's SetupWizardScreen used to ask for in the
|
||||
// terminal before the server could even start -- server identity, the public
|
||||
// network fields a real deployment needs, and a named operator account to
|
||||
// replace the generated break-glass password -- except every field here
|
||||
// already has a working default (see quickstart's bootstrap_env), so nothing
|
||||
// blocks Start anymore. This just walks through customizing it.
|
||||
//
|
||||
// Every step's "Continue" calls the same /api/actions/* route the equivalent
|
||||
// Server Settings / Operators screen uses, with a fixed reason instead of an
|
||||
// operator-typed one and confirm:true immediately (no dry-run screen). The
|
||||
// rest of the panel asks an operator to justify a change to a live,
|
||||
// populated deployment; here the operator IS the only account that has ever
|
||||
// existed, configuring a server nobody else is using yet -- asking "why are
|
||||
// you naming your own server" is friction with no audit value.
|
||||
const WIZARD_REASON = "Set from the first-run setup wizard";
|
||||
|
||||
type StepId = "welcome" | "identity" | "network" | "botapi" | "account" | "done";
|
||||
|
||||
const STEP_ORDER: StepId[] = ["welcome", "identity", "network", "botapi", "account", "done"];
|
||||
const STEP_LABEL: Record<StepId, string> = {
|
||||
welcome: "Welcome",
|
||||
identity: "Identity",
|
||||
network: "Network",
|
||||
botapi: "Bot API",
|
||||
account: "Account",
|
||||
done: "Done"
|
||||
};
|
||||
|
||||
// No prop for "leave the wizard early": this only ever shows on a genuinely
|
||||
// first-ever start, before there is a real deployment for a Skip to defer
|
||||
// anything about -- see the Welcome step. The only way out is Done's
|
||||
// "Finish setup & restart", which reloads the page once the new process
|
||||
// answers; that reload is what dismisses this component (a fresh
|
||||
// /api/session comes back with setup_completed=true).
|
||||
export function SetupWizard() {
|
||||
const [step, setStep] = useState<StepId>("welcome");
|
||||
|
||||
function goTo(next: StepId) {
|
||||
setStep(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page setup-wizard-page">
|
||||
<AppBackground />
|
||||
<section className="login-panel setup-wizard-panel">
|
||||
<div className="login-head">
|
||||
<div className="brand brand-elevated">
|
||||
<span>
|
||||
<strong>{"Let's set up your server"}</strong>
|
||||
<small>{"First-run setup"}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="login-head-actions">
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-steps">
|
||||
{STEP_ORDER.map((id, index) => {
|
||||
const currentIndex = STEP_ORDER.indexOf(step);
|
||||
const state = index === currentIndex ? "active" : index < currentIndex ? "done" : "";
|
||||
return (
|
||||
<div key={id} className={`command-step ${state}`}>
|
||||
<span>{index < currentIndex ? <Check size={12} /> : index + 1}</span>
|
||||
<strong>{STEP_LABEL[id]}</strong>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{step === "welcome" && <WelcomeStep onNext={() => goTo("identity")} />}
|
||||
{step === "identity" && <IdentityStep onNext={() => goTo("network")} />}
|
||||
{step === "network" && <NetworkStep onNext={() => goTo("botapi")} />}
|
||||
{step === "botapi" && <BotApiStep onNext={() => goTo("account")} />}
|
||||
{step === "account" && <AccountStep onNext={() => goTo("done")} />}
|
||||
{step === "done" && <DoneStep />}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardActions({ children }: { children: ReactNode }) {
|
||||
return <div className="wizard-actions">{children}</div>;
|
||||
}
|
||||
|
||||
function WelcomeStep({ onNext }: { onNext: () => void }) {
|
||||
return (
|
||||
<div className="wizard-step-body">
|
||||
<p className="wizard-welcome-greeting">{"Hi!"}</p>
|
||||
<p>
|
||||
{"Let's get your server set up -- a name, an address for clients, and an account of "}
|
||||
{"your own. Takes about a minute, and everything here stays editable later."}
|
||||
</p>
|
||||
<WizardActions>
|
||||
<button className="btn primary icon-text" type="button" onClick={onNext}>
|
||||
{"Get started"} <ArrowRight size={15} />
|
||||
</button>
|
||||
</WizardActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentityStep({ onNext }: { onNext: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [iconExt, setIconExt] = useState<string | undefined>(undefined);
|
||||
const [iconModalOpen, setIconModalOpen] = useState(false);
|
||||
const [iconBust, setIconBust] = useState(0);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.serverIdentity()
|
||||
.then((info) => {
|
||||
if (cancelled) return;
|
||||
setName(info.name);
|
||||
setDescription(info.description);
|
||||
setIconExt(info.icon_ext);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch((err) => { if (!cancelled) { setError(errorMessage(err)); setLoaded(true); } });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.action("/api/actions/set-server-identity", {
|
||||
command_id: "", reason: WIZARD_REASON, confirm: true, name, description
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
onNext();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wizard-step-body">
|
||||
<p className="wizard-step-hint">{"Shown to clients when they add this server, and in the sidebar here."}</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="wizard-identity-row">
|
||||
<div className="avatar-edit-slot">
|
||||
{iconExt ? (
|
||||
<img className="avatar-photo-img" src={api.serverIconURL() + `&b=${iconBust}`} alt="" style={{ width: 72, height: 72 }} />
|
||||
) : (
|
||||
<div className="avatar-fallback server-icon-fallback" style={{ width: 72, height: 72 }}>
|
||||
<ImagePlus size={22} />
|
||||
</div>
|
||||
)}
|
||||
<button className="icon-btn avatar-edit-btn" type="button" aria-label={"Add server icon"} onClick={() => setIconModalOpen(true)}>
|
||||
<ImagePlus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="server-identity-fields">
|
||||
<label className="form-field"><span>{"Name"}</span><input value={name} maxLength={128} placeholder={"OwpenGram"} disabled={!loaded} onChange={(event) => setName(event.target.value)} /></label>
|
||||
<label className="form-field"><span>{"Description"}</span><textarea rows={2} value={description} maxLength={512} disabled={!loaded} onChange={(event) => setDescription(event.target.value)} /></label>
|
||||
</div>
|
||||
</div>
|
||||
<WizardActions>
|
||||
<button className="btn primary icon-text" type="button" disabled={busy || !loaded} onClick={() => void submit()}>
|
||||
{busy ? <Loader2 className="spin" size={15} /> : <ArrowRight size={15} />}
|
||||
{"Continue"}
|
||||
</button>
|
||||
</WizardActions>
|
||||
{iconModalOpen && (
|
||||
<ServerIconModal
|
||||
hasIcon={!!iconExt}
|
||||
autoReason={WIZARD_REASON}
|
||||
onClose={() => setIconModalOpen(false)}
|
||||
onDone={() => { setIconBust((n) => n + 1); void api.serverIdentity().then((info) => setIconExt(info.icon_ext)); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NETWORK_FIELDS: { key: string; label: string; hint: string; placeholder: string }[] = [
|
||||
{
|
||||
key: "TELESRV_ADVERTISE_IP",
|
||||
label: "Server public IP or hostname",
|
||||
hint: "What clients connect to. Fine to leave as 127.0.0.1 for local testing.",
|
||||
placeholder: "127.0.0.1"
|
||||
},
|
||||
{
|
||||
key: "TELESRV_PUBLIC_BASE_URL",
|
||||
label: "Public base URL",
|
||||
hint: "Used for links this server generates -- invites, sticker packs. e.g. https://example.com",
|
||||
placeholder: "http://127.0.0.1:2401"
|
||||
},
|
||||
{
|
||||
key: "TELESRV_PUBLIC_APP_SCHEME",
|
||||
label: "Custom app link scheme",
|
||||
hint: "Must match what your client builds were compiled with.",
|
||||
placeholder: "owpg"
|
||||
}
|
||||
];
|
||||
|
||||
function NetworkStep({ onNext }: { onNext: () => void }) {
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.serverEnv()
|
||||
.then((groups) => {
|
||||
if (cancelled) return;
|
||||
const next: Record<string, string> = {};
|
||||
for (const group of groups) {
|
||||
for (const field of group.fields) {
|
||||
if (NETWORK_FIELDS.some((f) => f.key === field.key)) next[field.key] = field.value;
|
||||
}
|
||||
}
|
||||
setValues(next);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch((err) => { if (!cancelled) { setError(errorMessage(err)); setLoaded(true); } });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.action("/api/actions/update-server-env", {
|
||||
command_id: "", reason: WIZARD_REASON, confirm: true, values
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
onNext();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wizard-step-body">
|
||||
<p className="wizard-step-hint">{"Takes effect once setup finishes below -- that last step restarts the server."}</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{NETWORK_FIELDS.map((field) => (
|
||||
<label key={field.key} className="form-field env-field">
|
||||
<span>{field.label}</span>
|
||||
<span className="env-field-desc">{field.hint}</span>
|
||||
<input
|
||||
value={values[field.key] ?? ""}
|
||||
placeholder={field.placeholder}
|
||||
disabled={!loaded}
|
||||
onChange={(event) => setValues((prev) => ({ ...prev, [field.key]: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<WizardActions>
|
||||
<button className="btn primary icon-text" type="button" disabled={busy || !loaded} onClick={() => void submit()}>
|
||||
{busy ? <Loader2 className="spin" size={15} /> : <ArrowRight size={15} />}
|
||||
{"Continue"}
|
||||
</button>
|
||||
</WizardActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BOT_API_KEY = "TELESRV_BOT_API_ADDR";
|
||||
const BOT_API_DEFAULT_ADDR = "127.0.0.1:2500";
|
||||
|
||||
// An empty TELESRV_BOT_API_ADDR is what disables the gateway: botapi.Start
|
||||
// returns early on a blank address. A malformed or already-taken one is worth
|
||||
// catching here rather than server-side, because cmd/telesrv/main.go turns a
|
||||
// failed botapi.Start into a fatal "start bot api" error -- and the step that
|
||||
// applies this is the wizard's own restart, so a typo would leave the operator
|
||||
// staring at a server that never comes back.
|
||||
function botApiAddrError(addr: string): string {
|
||||
const value = addr.trim();
|
||||
if (value === "") return "Enter an address like " + BOT_API_DEFAULT_ADDR + ".";
|
||||
const colon = value.lastIndexOf(":");
|
||||
if (colon < 0) return "Include a port, for example " + BOT_API_DEFAULT_ADDR + ".";
|
||||
const port = Number(value.slice(colon + 1));
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return "Port must be a whole number between 1 and 65535.";
|
||||
return "";
|
||||
}
|
||||
|
||||
function BotApiStep({ onNext }: { onNext: () => void }) {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [addr, setAddr] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.serverEnv()
|
||||
.then((groups) => {
|
||||
if (cancelled) return;
|
||||
for (const group of groups) {
|
||||
for (const field of group.fields) {
|
||||
if (field.key !== BOT_API_KEY) continue;
|
||||
setEnabled(field.value.trim() !== "");
|
||||
setAddr(field.value.trim());
|
||||
}
|
||||
}
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch((err) => { if (!cancelled) { setError(errorMessage(err)); setLoaded(true); } });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const addrError = enabled ? botApiAddrError(addr) : "";
|
||||
|
||||
async function submit() {
|
||||
if (addrError) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.action("/api/actions/update-server-env", {
|
||||
command_id: "", reason: WIZARD_REASON, confirm: true,
|
||||
values: { [BOT_API_KEY]: enabled ? addr.trim() : "" }
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
onNext();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wizard-step-body">
|
||||
<p className="wizard-step-hint">
|
||||
{"An HTTP gateway that lets bot libraries -- python-telegram-bot, aiogram and friends -- "}
|
||||
{"talk to this server. Leave it off if you are not running bots; you can turn it on later in Server Settings."}
|
||||
</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<label className="checkline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={!loaded}
|
||||
onChange={(event) => {
|
||||
const next = event.target.checked;
|
||||
setEnabled(next);
|
||||
if (next && addr.trim() === "") setAddr(BOT_API_DEFAULT_ADDR);
|
||||
}}
|
||||
/>
|
||||
{" Enable the Bot API gateway"}
|
||||
</label>
|
||||
{enabled && (
|
||||
<label className="form-field env-field">
|
||||
<span>{"Listen address"}</span>
|
||||
<span className="env-field-desc">
|
||||
{"Keep 127.0.0.1 to accept only local bots; use 0.0.0.0 to expose it. "}
|
||||
{"The server will refuse to start if this port is already taken."}
|
||||
</span>
|
||||
<input
|
||||
value={addr}
|
||||
placeholder={BOT_API_DEFAULT_ADDR}
|
||||
disabled={!loaded}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
onChange={(event) => setAddr(event.target.value)}
|
||||
/>
|
||||
{addrError && <span className="env-field-desc">{addrError}</span>}
|
||||
</label>
|
||||
)}
|
||||
<WizardActions>
|
||||
<button className="btn primary icon-text" type="button" disabled={busy || !loaded || addrError !== ""} onClick={() => void submit()}>
|
||||
{busy ? <Loader2 className="spin" size={15} /> : <ArrowRight size={15} />}
|
||||
{"Continue"}
|
||||
</button>
|
||||
</WizardActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountStep({ onNext }: { onNext: () => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const incomplete = username.trim().length < 3 || password.trim() === "";
|
||||
|
||||
async function submit() {
|
||||
if (incomplete) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.action("/api/actions/create-admin-operator", {
|
||||
command_id: "", reason: WIZARD_REASON, confirm: true,
|
||||
username: username.trim(), password, permissions: ["*"], enabled: true
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
onNext();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wizard-step-body">
|
||||
<p className="wizard-step-hint">{"Replace the generated password with a login of your own."}</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<label className="form-field"><span>{"Username"}</span><input autoFocus value={username} spellCheck={false} autoCapitalize="none" placeholder={"letters, digits, dot, dash or underscore"} onChange={(event) => setUsername(event.target.value)} /></label>
|
||||
<label className="form-field"><span>{"Password"}</span><input type="password" value={password} autoComplete="new-password" onChange={(event) => setPassword(event.target.value)} /></label>
|
||||
<WizardActions>
|
||||
<button className="btn" type="button" onClick={onNext}>{"Skip for now"}</button>
|
||||
<button className="btn primary icon-text" type="button" disabled={busy || incomplete} onClick={() => void submit()}>
|
||||
{busy ? <Loader2 className="spin" size={15} /> : <UserPlus size={15} />}
|
||||
{"Create account & continue"}
|
||||
</button>
|
||||
</WizardActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DoneStep marks setup complete and restarts, rather than leaving that for
|
||||
// later -- the network fields two steps back only take effect after a
|
||||
// restart, and asking the operator to remember to go find Restart in
|
||||
// Services afterward is exactly the kind of loose end this wizard exists to
|
||||
// close. useAdminRestartWatcher (shared with Services' own Restart button)
|
||||
// reloads the page once a genuinely new process answers; passed a
|
||||
// beforeReload that logs out first here specifically (Services' own Restart
|
||||
// button doesn't), so the reload lands back on the login form instead of
|
||||
// straight into the shell still signed in as "owpengram" -- the whole point
|
||||
// of the Account step just before this one was to have a real login to
|
||||
// switch to instead. The generated password stops working server-side the
|
||||
// moment complete-setup runs (see identity.Store.TemporaryPasswordMatches),
|
||||
// independent of this logout; this just makes sure the browser doesn't
|
||||
// carry the old session forward and paper over that.
|
||||
function DoneStep() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const restartWatcher = useAdminRestartWatcher();
|
||||
|
||||
async function finish() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const completeResult = await api.action("/api/actions/complete-setup", { command_id: "", reason: WIZARD_REASON, confirm: true });
|
||||
if (completeResult.error) {
|
||||
setError(completeResult.error);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
const restartResult = await api.action("/api/actions/restart-server", { command_id: "", reason: WIZARD_REASON, confirm: true });
|
||||
if (restartResult.error) {
|
||||
setError(restartResult.error);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
void restartWatcher.watch(150000, { beforeReload: async () => { await api.logout(); } });
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wizard-step-body">
|
||||
<p>
|
||||
{"That's the essentials. Finishing restarts the server so the network and Bot API settings from "}
|
||||
{"the earlier steps take effect. Everything here stays editable from Server Settings and Operators any time."}
|
||||
</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<WizardActions>
|
||||
<button className="btn primary icon-text" type="button" disabled={busy} onClick={() => void finish()}>
|
||||
{busy ? <Loader2 className="spin" size={15} /> : <Rocket size={15} />}
|
||||
{"Finish setup & restart"}
|
||||
</button>
|
||||
</WizardActions>
|
||||
{restartWatcher.waiting && (
|
||||
<RestartOverlay timedOut={false} onDismiss={restartWatcher.dismiss} />
|
||||
)}
|
||||
{restartWatcher.timedOut && (
|
||||
<RestartOverlay timedOut={true} onDismiss={restartWatcher.dismiss} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import { ArrowLeft, type LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Navigate } from "../routing";
|
||||
|
||||
// A full-height "this page is not for you" screen, in place of an alert bar
|
||||
// bolted to the top of an otherwise empty page frame.
|
||||
//
|
||||
// The status code is set large and ghosted behind the message rather than
|
||||
// spelled out in the text: an operator recognises 403 at a glance, and the
|
||||
// words are then free to say the useful part -- which right is missing and who
|
||||
// can grant it.
|
||||
export function StatusScreen({
|
||||
code,
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
detail,
|
||||
navigate
|
||||
}: {
|
||||
code: string;
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
// The machine-readable thing behind the message: a permission name, a config
|
||||
// key. Shown in mono, because it is what someone will have to copy.
|
||||
detail?: string;
|
||||
navigate?: Navigate;
|
||||
}) {
|
||||
return (
|
||||
<section className="status-screen">
|
||||
<span className="status-screen-code" aria-hidden="true">{code}</span>
|
||||
<div className="status-screen-body">
|
||||
<span className="status-screen-icon"><Icon size={26} /></span>
|
||||
<h1>{title}</h1>
|
||||
<p>{children}</p>
|
||||
{detail && <code className="status-screen-detail">{detail}</code>}
|
||||
{navigate && (
|
||||
<button
|
||||
className="btn primary icon-text"
|
||||
type="button"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
<ArrowLeft size={15} /> {"Back to overview"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -72,25 +72,11 @@ export function StatusItem({ label, value, tone }: { label: string; value: strin
|
|||
);
|
||||
}
|
||||
|
||||
export function Metric({
|
||||
label,
|
||||
value,
|
||||
tone = "neutral",
|
||||
mono = false,
|
||||
loading = false
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: Tone;
|
||||
mono?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
export function Metric({ label, value, tone = "neutral", mono = false }: { label: string; value: string; tone?: Tone; mono?: boolean }) {
|
||||
return (
|
||||
<div className={`metric ${tone}`}>
|
||||
<span>{label}</span>
|
||||
<strong className={mono ? "mono" : ""} aria-busy={loading || undefined}>
|
||||
{loading ? <span className="skeleton skeleton-text" aria-label="Loading" /> : value}
|
||||
</strong>
|
||||
<strong className={mono ? "mono" : ""}>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -133,26 +119,6 @@ export function EmptyRow({ colSpan }: { colSpan: number }) {
|
|||
return <tr><td colSpan={colSpan} className="empty-cell">{"No results"}</td></tr>;
|
||||
}
|
||||
|
||||
// Placeholder rows for a table that has nothing yet *because it is still
|
||||
// loading* -- distinct from EmptyRow, which asserts the query genuinely
|
||||
// returned nothing. Showing "No results" during the first fetch reads as a
|
||||
// wrong answer rather than a pending one.
|
||||
export function LoadingRow({ colSpan, rows = 3 }: { colSpan: number; rows?: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: rows }, (_, row) => (
|
||||
<tr key={row} aria-busy="true">
|
||||
{Array.from({ length: colSpan }, (_unused, cell) => (
|
||||
<td key={cell}>
|
||||
<span className="skeleton skeleton-text" aria-label={cell === 0 ? "Loading" : undefined} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingSurface({ label }: { label: string }) {
|
||||
return <section className="surface"><div className="loading-line">{label}</div></section>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
// Last-known values for screens that are visited repeatedly.
|
||||
//
|
||||
// Navigating back to the dashboard used to re-mount it with empty state, so an
|
||||
// operator watched the same numbers redraw from skeletons every time. The
|
||||
// screens already refresh themselves; what they lacked was something to show
|
||||
// while that happens. Reading from here on mount means the page opens on the
|
||||
// figures it had, and the refresh quietly replaces them.
|
||||
//
|
||||
// Kept in module memory rather than localStorage on purpose. This is admin data
|
||||
// -- account counts, storage figures -- and it has no business outliving the
|
||||
// tab or sitting on disk. It dies on reload, and clearAdminCache() drops it at
|
||||
// sign-out so the next operator in the same tab never sees the previous one's
|
||||
// figures.
|
||||
|
||||
const store = new Map<string, unknown>();
|
||||
|
||||
export function cacheGet<T>(key: string): T | undefined {
|
||||
return store.get(key) as T | undefined;
|
||||
}
|
||||
|
||||
export function cacheSet<T>(key: string, value: T): void {
|
||||
store.set(key, value);
|
||||
}
|
||||
|
||||
export function clearAdminCache(): void {
|
||||
store.clear();
|
||||
}
|
||||
|
||||
// Keys live here rather than as loose strings at each call site, so a typo
|
||||
// cannot quietly create a second cache that never hits.
|
||||
export const cacheKeys = {
|
||||
dashboard: "dashboard",
|
||||
storageStats: "storage.stats",
|
||||
storageAccounts: "storage.accounts"
|
||||
} as const;
|
||||
|
|
@ -193,20 +193,3 @@ 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())}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,423 +0,0 @@
|
|||
import { KeyRound, Lock, RefreshCw, ShieldCheck, UserPlus, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, EmptyRow, LoadingRow, PageFrame, QueryPanel, SectionHead } from "../components/ui";
|
||||
import { groupPermissions, permissionAll, permissionHint, permissionTitle } from "../permissions";
|
||||
import type { AdminConsoleUser, AdminConsoleSystemOperator } from "../types";
|
||||
|
||||
// The operator-accounts screen. The table only reports; every change happens in
|
||||
// a modal and goes through the panel's usual reason + dry-run + confirm flow,
|
||||
// because handing somebody the run of the console deserves the same "here is
|
||||
// what this will do" step as freezing an account.
|
||||
//
|
||||
// Everything here is additionally enforced server-side by admins.manage --
|
||||
// hiding the section is a convenience, not the boundary.
|
||||
export function AdminUsersPage() {
|
||||
const [rows, setRows] = useState<AdminConsoleUser[]>([]);
|
||||
const [system, setSystem] = useState<AdminConsoleSystemOperator | null>(null);
|
||||
const [available, setAvailable] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [editing, setEditing] = useState<AdminConsoleUser | null>(null);
|
||||
const [resetting, setResetting] = useState<AdminConsoleUser | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.adminUsers();
|
||||
setRows(result.rows ?? []);
|
||||
setSystem(result.system ?? null);
|
||||
setAvailable(result.available_permissions ?? []);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setLoaded(true);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageFrame eyebrow={"ACCESS / OPERATORS"} title={"Admin operators"}>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<QueryPanel>
|
||||
<div className="toolbar">
|
||||
<button className="btn primary icon-text" type="button" onClick={() => setCreating(true)}>
|
||||
<UserPlus size={15} /> {"New operator"}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={() => void load()} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</QueryPanel>
|
||||
|
||||
<SectionHead title={"Operators"} />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Username"}</th>
|
||||
<th>{"Can do"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Last login"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* The built-in operator first: it has the most rights and no
|
||||
database row, so a list that started with the named accounts
|
||||
would put the most powerful login last, or nowhere. */}
|
||||
{system && (
|
||||
<tr>
|
||||
<td className="mono">
|
||||
{system.username} <span className="pill">{"built-in"}</span>
|
||||
</td>
|
||||
<td><PermissionChips permissions={system.permissions} /></td>
|
||||
<td><span className="pill good">{"Enabled"}</span></td>
|
||||
<td className="mono">{"—"}</td>
|
||||
<td>
|
||||
<span className="muted icon-text">
|
||||
<Lock size={13} /> {"Set in the server environment"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="mono">{row.username}</td>
|
||||
<td><PermissionChips permissions={row.permissions} /></td>
|
||||
<td>
|
||||
{row.enabled
|
||||
? <span className="pill good">{"Enabled"}</span>
|
||||
: <span className="pill">{"Disabled"}</span>}
|
||||
</td>
|
||||
<td className="mono">{row.last_login_at ? new Date(row.last_login_at).toLocaleString() : "—"}</td>
|
||||
<td>
|
||||
<button className="btn icon-text" type="button" onClick={() => setEditing(row)}>
|
||||
<ShieldCheck size={14} /> {"Access"}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={() => setResetting(row)}>
|
||||
<KeyRound size={14} /> {"Password"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && !system &&
|
||||
(busy || !loaded ? <LoadingRow colSpan={5} /> : <EmptyRow colSpan={5} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<OperatorModal
|
||||
title={"New operator"}
|
||||
available={available}
|
||||
onClose={() => setCreating(false)}
|
||||
onDone={() => { setCreating(false); void load(); }}
|
||||
/>
|
||||
)}
|
||||
{editing && (
|
||||
<OperatorModal
|
||||
title={`Access for ${editing.username}`}
|
||||
available={available}
|
||||
existing={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onDone={() => { setEditing(null); void load(); }}
|
||||
/>
|
||||
)}
|
||||
{resetting && (
|
||||
<PasswordModal
|
||||
operator={resetting}
|
||||
onClose={() => setResetting(null)}
|
||||
onDone={() => { setResetting(null); void load(); }}
|
||||
/>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionChips({ permissions }: { permissions: string[] }) {
|
||||
if (permissions.length === 0) {
|
||||
return <span className="muted">{"nothing yet"}</span>;
|
||||
}
|
||||
return (
|
||||
<span className="chip-row">
|
||||
{permissions.map((p) => (
|
||||
<span className="chip" key={p} title={p}>{permissionTitle(p)}</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// PermissionPicker lists the rights by what they let someone do, split into the
|
||||
// section of the console each governs -- twenty-six checkboxes in one run is a
|
||||
// wall nobody reads, and the grouping is what makes "what can this person
|
||||
// actually touch" answerable at a glance.
|
||||
//
|
||||
// The raw permission string stays as each row's tooltip, so the screen never
|
||||
// hides what is actually being stored.
|
||||
//
|
||||
// "*" gets its own row rather than a box in the grid below, because
|
||||
// assignablePermissions() deliberately leaves it out of the assignable list
|
||||
// (cmd/telesrv-admin/security.go) -- without this row an operator holding the
|
||||
// wildcard, like the one the first-run wizard creates, renders as every box
|
||||
// unticked while Has() answers true for everything, and there is no way to take
|
||||
// it away again. While it is on the grid is disabled: normalisePermissions
|
||||
// collapses "*" plus anything back to just "*", so ticking a box there would be
|
||||
// a no-op the screen would otherwise show as a change.
|
||||
function PermissionPicker({
|
||||
available,
|
||||
selected,
|
||||
onToggle,
|
||||
onToggleGroup,
|
||||
onToggleAll
|
||||
}: {
|
||||
available: string[];
|
||||
selected: string[];
|
||||
onToggle: (permission: string, on: boolean) => void;
|
||||
onToggleGroup: (permissions: string[], on: boolean) => void;
|
||||
onToggleAll: (on: boolean) => void;
|
||||
}) {
|
||||
const full = selected.includes(permissionAll);
|
||||
return (
|
||||
<div className="permission-groups">
|
||||
<section className="permission-group">
|
||||
<div className="permission-grid">
|
||||
<label className="permission-item" title={permissionAll}>
|
||||
<input type="checkbox" checked={full} onChange={(event) => onToggleAll(event.target.checked)} />
|
||||
<span className="permission-copy">
|
||||
<strong>{"Full access"}</strong>
|
||||
<small>{"Every right below, including ones added in future updates. Turn off to pick rights individually."}</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
{groupPermissions(available).map((group) => {
|
||||
const all = group.permissions.every((p) => selected.includes(p));
|
||||
return (
|
||||
<section className="permission-group" key={group.title}>
|
||||
<div className="permission-group-head">
|
||||
<div>
|
||||
<strong>{group.title}</strong>
|
||||
<small>{group.hint}</small>
|
||||
</div>
|
||||
<button
|
||||
className="btn compact"
|
||||
type="button"
|
||||
disabled={full}
|
||||
onClick={() => onToggleGroup(group.permissions, !all)}
|
||||
>
|
||||
{all ? "Clear" : "Select all"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="permission-grid">
|
||||
{group.permissions.map((permission) => (
|
||||
<label className="permission-item" key={permission} title={permission}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={full || selected.includes(permission)}
|
||||
disabled={full}
|
||||
onChange={(event) => onToggle(permission, event.target.checked)}
|
||||
/>
|
||||
<span className="permission-copy">
|
||||
<strong>{permissionTitle(permission)}</strong>
|
||||
<small>{permissionHint(permission)}</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// OperatorModal creates a new operator, or edits an existing one's access. The
|
||||
// same shape either way: the only difference is whether a username and password
|
||||
// are being chosen.
|
||||
//
|
||||
// Laid out as head / scrolling body / action bar like every other command modal
|
||||
// in the panel, so a long permission list scrolls inside the dialog instead of
|
||||
// pushing its own confirm button off the screen.
|
||||
function OperatorModal({
|
||||
title,
|
||||
available,
|
||||
existing,
|
||||
onClose,
|
||||
onDone
|
||||
}: {
|
||||
title: string;
|
||||
available: string[];
|
||||
existing?: AdminConsoleUser;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [username, setUsername] = useState(existing?.username ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [permissions, setPermissions] = useState<string[]>(existing?.permissions ?? []);
|
||||
const [enabled, setEnabled] = useState(existing?.enabled ?? true);
|
||||
const isEdit = Boolean(existing);
|
||||
|
||||
// Only the shape the server insists on: a username it will accept, and a
|
||||
// password that is actually present. Length is the operator's business.
|
||||
const incomplete = isEdit
|
||||
? false
|
||||
: username.trim().length < 3 || password.trim() === "";
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{"Operators"}</div>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
|
||||
<div className="command-body">
|
||||
{!isEdit && (
|
||||
<div className="operator-identity">
|
||||
<label className="duration-field">
|
||||
<span>{"Username"}</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={username}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
placeholder={"letters, digits, dot, dash or underscore"}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{"Password"}</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="new-password"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PermissionPicker
|
||||
available={available}
|
||||
selected={permissions}
|
||||
onToggle={(permission, on) =>
|
||||
setPermissions((current) =>
|
||||
on ? [...current, permission] : current.filter((p) => p !== permission)
|
||||
)
|
||||
}
|
||||
onToggleGroup={(group, on) =>
|
||||
setPermissions((current) =>
|
||||
on
|
||||
? [...current, ...group.filter((p) => !current.includes(p))]
|
||||
: current.filter((p) => !group.includes(p))
|
||||
)
|
||||
}
|
||||
onToggleAll={(on) =>
|
||||
setPermissions((current) =>
|
||||
on ? [permissionAll] : current.filter((p) => p !== permissionAll)
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<label className="permission-item standalone">
|
||||
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
|
||||
<span className="permission-copy">
|
||||
<strong>{"Account is enabled"}</strong>
|
||||
<small>{"A disabled operator cannot sign in"}</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{isEdit && (
|
||||
<Alert>{"The new access applies from this operator's next request. They stay signed in."}</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-actions toolbar">
|
||||
<button className="btn" type="button" onClick={onClose}>{"Cancel"}</button>
|
||||
<ActionButton
|
||||
label={isEdit ? "Save access" : "Create operator"}
|
||||
path={isEdit ? "/api/actions/set-admin-operator-access" : "/api/actions/create-admin-operator"}
|
||||
tone="primary"
|
||||
disabled={incomplete}
|
||||
icon={isEdit ? <ShieldCheck size={15} /> : <UserPlus size={15} />}
|
||||
payload={() =>
|
||||
isEdit
|
||||
? { id: existing?.id, permissions, enabled }
|
||||
: { username: username.trim(), password, permissions, enabled }
|
||||
}
|
||||
onDone={onDone}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordModal({
|
||||
operator,
|
||||
onClose,
|
||||
onDone
|
||||
}: {
|
||||
operator: AdminConsoleUser;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal narrow" role="dialog" aria-modal="true" aria-label={"Set password"}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{"Operators"}</div>
|
||||
<h2>{`Password for ${operator.username}`}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
|
||||
<div className="command-body">
|
||||
<label className="duration-field">
|
||||
<span>{"New password"}</span>
|
||||
<input
|
||||
autoFocus
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="new-password"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Alert>{"Changing the password signs this operator out of any session they already have."}</Alert>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions toolbar">
|
||||
<button className="btn" type="button" onClick={onClose}>{"Cancel"}</button>
|
||||
<ActionButton
|
||||
label={"Set password"}
|
||||
path={"/api/actions/set-admin-operator-password"}
|
||||
tone="primary"
|
||||
disabled={password.trim() === ""}
|
||||
icon={<KeyRound size={15} />}
|
||||
payload={() => ({ id: operator.id, password })}
|
||||
onDone={onDone}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ export function BroadcastsPage() {
|
|||
}, []);
|
||||
|
||||
const rows = data?.rows ?? [];
|
||||
const inFlight = rows.filter((row) => !row.EnumerationDone || row.SentCount + row.FailedCount < row.TargetCount).length;
|
||||
const inFlight = rows.filter((row) => row.SentCount + row.FailedCount < row.TotalCount).length;
|
||||
const canGoPrev = history.length > 0 && !busy;
|
||||
const canGoNext = Boolean(data?.has_more) && !busy;
|
||||
|
||||
|
|
@ -110,7 +110,7 @@ export function BroadcastsPage() {
|
|||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const delivered = row.SentCount + row.FailedCount;
|
||||
const done = row.EnumerationDone && row.TargetCount > 0 && delivered >= row.TargetCount;
|
||||
const done = row.TotalCount > 0 && delivered >= row.TotalCount;
|
||||
return (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
|
|
@ -118,7 +118,7 @@ export function BroadcastsPage() {
|
|||
<td>{row.TargetMode === "all" ? <Badge tone="warn">{"All users"}</Badge> : <Badge>{"Selected"}</Badge>}</td>
|
||||
<td>{row.SentCount}</td>
|
||||
<td>{row.FailedCount > 0 ? <Badge tone="danger">{row.FailedCount}</Badge> : row.FailedCount}</td>
|
||||
<td>{row.TargetCount}</td>
|
||||
<td>{row.TotalCount}</td>
|
||||
<td>{row.CreatedBy || "-"}</td>
|
||||
<td>
|
||||
{formatDate(row.CreatedAt)}
|
||||
|
|
|
|||
|
|
@ -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 `${formatCurrency(row.CryptoAmount, row.CryptoCurrency)} (${base})`;
|
||||
return `${base} (${formatCurrency(row.CryptoAmount, row.CryptoCurrency)})`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,19 +17,13 @@ import {
|
|||
} from "lucide-react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { cacheGet, cacheKeys, cacheSet } from "../lib/cache";
|
||||
import { Alert } from "../components/ui";
|
||||
import type { Navigate } from "../routing";
|
||||
import { formatBytes, formatQuantity } from "../lib/format";
|
||||
import type { DashboardResponse } from "../types";
|
||||
|
||||
export function Dashboard({ navigate }: { navigate: Navigate }) {
|
||||
// Seeded from the last values this session saw, so coming back to the
|
||||
// dashboard opens on the numbers instead of on a grid of skeletons. The
|
||||
// 15s refresh below still runs and replaces them.
|
||||
const [data, setData] = useState<DashboardResponse | null>(
|
||||
() => cacheGet<DashboardResponse>(cacheKeys.dashboard) ?? null
|
||||
);
|
||||
const [data, setData] = useState<DashboardResponse | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -37,7 +31,6 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
|||
async function load() {
|
||||
try {
|
||||
const res = await api.dashboard();
|
||||
cacheSet(cacheKeys.dashboard, res);
|
||||
if (!cancelled) setData(res);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load dashboard");
|
||||
|
|
@ -65,8 +58,7 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
|||
<StatTile
|
||||
icon={<Flag />}
|
||||
label="Pending reports"
|
||||
value={counts ? formatQuantity(String(counts.PendingReports)) : ""}
|
||||
loading={!counts && !error}
|
||||
value={counts ? formatQuantity(String(counts.PendingReports)) : "…"}
|
||||
tone={counts && counts.PendingReports > 0 ? "warn" : "good"}
|
||||
href="/moderation"
|
||||
navigate={navigate}
|
||||
|
|
@ -74,8 +66,7 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
|||
<StatTile
|
||||
icon={<BadgeCheck />}
|
||||
label="Verification requests"
|
||||
value={counts ? formatQuantity(String(counts.PendingVerifications)) : ""}
|
||||
loading={!counts && !error}
|
||||
value={counts ? formatQuantity(String(counts.PendingVerifications)) : "…"}
|
||||
tone={counts && counts.PendingVerifications > 0 ? "warn" : "good"}
|
||||
href="/verification"
|
||||
navigate={navigate}
|
||||
|
|
@ -83,44 +74,27 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
|||
</Section>
|
||||
|
||||
<Section title="People & chats">
|
||||
<StatTile
|
||||
icon={<Users />}
|
||||
label="Users"
|
||||
value={counts ? formatQuantity(String(counts.Users)) : ""}
|
||||
loading={!counts && !error}
|
||||
href="/accounts"
|
||||
navigate={navigate}
|
||||
/>
|
||||
<StatTile icon={<Users />} label="Users" value={counts ? formatQuantity(String(counts.Users)) : "…"} href="/accounts" navigate={navigate} />
|
||||
<StatTile
|
||||
icon={<Activity />}
|
||||
label="Online now"
|
||||
value={counts ? formatQuantity(String(counts.OnlineUsers)) : ""}
|
||||
loading={!counts && !error}
|
||||
value={counts ? formatQuantity(String(counts.OnlineUsers)) : "…"}
|
||||
sub="last 5 min"
|
||||
href="/accounts"
|
||||
navigate={navigate}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Bot />}
|
||||
label="Bots"
|
||||
value={counts ? formatQuantity(String(counts.Bots)) : ""}
|
||||
loading={!counts && !error}
|
||||
href="/bots"
|
||||
navigate={navigate}
|
||||
/>
|
||||
<StatTile icon={<Bot />} label="Bots" value={counts ? formatQuantity(String(counts.Bots)) : "…"} href="/bots" navigate={navigate} />
|
||||
<StatTile
|
||||
icon={<Radio />}
|
||||
label="Channels"
|
||||
value={counts ? formatQuantity(String(counts.BroadcastChannels)) : ""}
|
||||
loading={!counts && !error}
|
||||
value={counts ? formatQuantity(String(counts.BroadcastChannels)) : "…"}
|
||||
href="/channels"
|
||||
navigate={navigate}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<UsersRound />}
|
||||
label="Supergroups"
|
||||
value={counts ? formatQuantity(String(counts.Supergroups)) : ""}
|
||||
loading={!counts && !error}
|
||||
value={counts ? formatQuantity(String(counts.Supergroups)) : "…"}
|
||||
href="/channels"
|
||||
navigate={navigate}
|
||||
/>
|
||||
|
|
@ -130,24 +104,21 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
|||
<StatTile
|
||||
icon={<Sticker />}
|
||||
label="Sticker packs"
|
||||
value={counts ? formatQuantity(String(counts.StickerSets)) : ""}
|
||||
loading={!counts && !error}
|
||||
value={counts ? formatQuantity(String(counts.StickerSets)) : "…"}
|
||||
href="/stickers"
|
||||
navigate={navigate}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Smile />}
|
||||
label="Emoji packs"
|
||||
value={counts ? formatQuantity(String(counts.EmojiSets)) : ""}
|
||||
loading={!counts && !error}
|
||||
value={counts ? formatQuantity(String(counts.EmojiSets)) : "…"}
|
||||
href="/emoji"
|
||||
navigate={navigate}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Film />}
|
||||
label="GIFs"
|
||||
value={counts ? formatQuantity(String(counts.Gifs)) : ""}
|
||||
loading={!counts && !error}
|
||||
value={counts ? formatQuantity(String(counts.Gifs)) : "…"}
|
||||
sub="saved by users"
|
||||
href="/gif-catalog"
|
||||
navigate={navigate}
|
||||
|
|
@ -155,8 +126,7 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
|||
<StatTile
|
||||
icon={<Database />}
|
||||
label="Media storage used"
|
||||
value={storage ? formatBytes(storage.PhysicalBytes) : ""}
|
||||
loading={!storage && !error}
|
||||
value={storage ? formatBytes(storage.PhysicalBytes) : "…"}
|
||||
sub={storage ? `${storage.BackendKind} backend` : undefined}
|
||||
href="/storage"
|
||||
navigate={navigate}
|
||||
|
|
@ -168,32 +138,25 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
|||
icon={<Cpu />}
|
||||
label="CPU load"
|
||||
percent={host?.Ready ? host.CPUPercent : undefined}
|
||||
valueText={host?.Ready ? `${host.CPUPercent.toFixed(0)}%` : ""}
|
||||
loading={!host?.Ready && !error}
|
||||
valueText={host?.Ready ? `${host.CPUPercent.toFixed(0)}%` : "…"}
|
||||
/>
|
||||
<UsageTile
|
||||
icon={<MemoryStick />}
|
||||
label="RAM used"
|
||||
percent={host?.Ready && host.MemTotalBytes > 0 ? (host.MemUsedBytes / host.MemTotalBytes) * 100 : undefined}
|
||||
valueText={host?.Ready ? formatBytes(String(host.MemUsedBytes)) : ""}
|
||||
loading={!host?.Ready && !error}
|
||||
valueText={host?.Ready ? formatBytes(String(host.MemUsedBytes)) : "…"}
|
||||
sub={host?.Ready ? `of ${formatBytes(String(host.MemTotalBytes))}` : undefined}
|
||||
/>
|
||||
<UsageTile
|
||||
icon={<HardDrive />}
|
||||
label="Disk free"
|
||||
percent={
|
||||
host?.Ready && host.DiskReady && host.DiskTotalBytes > 0
|
||||
host?.Ready && host.DiskTotalBytes > 0
|
||||
? ((host.DiskTotalBytes - host.DiskFreeBytes) / host.DiskTotalBytes) * 100
|
||||
: undefined
|
||||
}
|
||||
// Skeleton only until the first host sample lands. After that a
|
||||
// missing disk reading is a real state, not a pending one -- it can
|
||||
// stay that way indefinitely, and a shimmer would promise a value
|
||||
// that is never coming.
|
||||
valueText={host?.Ready && host.DiskReady ? formatBytes(String(host.DiskFreeBytes)) : "—"}
|
||||
loading={!host?.Ready && !error}
|
||||
sub={host?.Ready && host.DiskReady ? `of ${formatBytes(String(host.DiskTotalBytes))}` : "no reading yet"}
|
||||
valueText={host?.Ready ? formatBytes(String(host.DiskFreeBytes)) : "…"}
|
||||
sub={host?.Ready ? `of ${formatBytes(String(host.DiskTotalBytes))}` : undefined}
|
||||
warnAbove={85}
|
||||
/>
|
||||
</Section>
|
||||
|
|
@ -222,8 +185,7 @@ function StatTile({
|
|||
sub,
|
||||
tone = "neutral",
|
||||
href,
|
||||
navigate,
|
||||
loading = false
|
||||
navigate
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
|
|
@ -232,7 +194,6 @@ function StatTile({
|
|||
tone?: Tone;
|
||||
href?: string;
|
||||
navigate?: Navigate;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const toneClass = tone === "neutral" ? "" : ` ${tone}`;
|
||||
const body = (
|
||||
|
|
@ -241,9 +202,7 @@ function StatTile({
|
|||
<span className="stat-tile-icon">{icon}</span>
|
||||
{tone === "warn" && <AlertTriangle size={15} className="stat-tile-open" />}
|
||||
</div>
|
||||
<div className="stat-tile-value" aria-busy={loading || undefined}>
|
||||
{loading ? <span className="skeleton skeleton-value" aria-label="Loading" /> : value}
|
||||
</div>
|
||||
<div className="stat-tile-value">{value}</div>
|
||||
<div className="stat-tile-label">{label}</div>
|
||||
{sub && <div className="stat-tile-sub">{sub}</div>}
|
||||
</>
|
||||
|
|
@ -274,8 +233,7 @@ function UsageTile({
|
|||
percent,
|
||||
valueText,
|
||||
sub,
|
||||
warnAbove = 90,
|
||||
loading = false
|
||||
warnAbove = 90
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
|
|
@ -283,7 +241,6 @@ function UsageTile({
|
|||
valueText: string;
|
||||
sub?: string;
|
||||
warnAbove?: number;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const clamped = percent === undefined ? 0 : Math.max(0, Math.min(100, percent));
|
||||
const tone: Tone = percent === undefined ? "neutral" : percent >= warnAbove ? "danger" : percent >= warnAbove - 15 ? "warn" : "neutral";
|
||||
|
|
@ -293,9 +250,7 @@ function UsageTile({
|
|||
<div className="stat-tile-head">
|
||||
<span className="stat-tile-icon">{icon}</span>
|
||||
</div>
|
||||
<div className="stat-tile-value" aria-busy={loading || undefined}>
|
||||
{loading ? <span className="skeleton skeleton-value" aria-label="Loading" /> : valueText}
|
||||
</div>
|
||||
<div className="stat-tile-value">{valueText}</div>
|
||||
<div className="stat-tile-label">{label}</div>
|
||||
{sub && <div className="stat-tile-sub">{sub}</div>}
|
||||
<div className="stat-tile-bar">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Check, ChevronRight, Copy, Loader2, RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { copyToClipboard } from "../clipboard";
|
||||
import { StaticLottie } from "../components/StaticLottie";
|
||||
import { Alert, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import type { EmojiListResponse, EmojiRow } from "../types";
|
||||
|
|
@ -44,7 +43,7 @@ function EmojiCard({ row }: { row: EmojiRow }) {
|
|||
|
||||
async function copy() {
|
||||
try {
|
||||
await copyToClipboard(row.DocumentID);
|
||||
await navigator.clipboard.writeText(row.DocumentID);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { ArrowLeft } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { MessageView } from "../components/MessageView";
|
||||
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
|
||||
import { formatUnix } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
|
|
@ -39,38 +38,32 @@ export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channel
|
|||
actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> {"Back to group messages"}</button>}
|
||||
>
|
||||
<div className="stacked-sections">
|
||||
<MessageView
|
||||
body={msg.Body}
|
||||
media={msg.Media}
|
||||
sender={msg.Post ? `Channel post in ${msg.ChannelID}` : `From ${msg.SenderUserID}`}
|
||||
meta={`${formatUnix(msg.Date)}${msg.EditDate ? ` · edited ${formatUnix(msg.EditDate)}` : ""}${msg.ViewsCount ? ` · ${msg.ViewsCount} views` : ""}`}
|
||||
badges={
|
||||
<>
|
||||
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
|
||||
{msg.Pinned && <Badge tone="warn">{"Pinned"}</Badge>}
|
||||
{msg.Post && <Badge>{"Channel post"}</Badge>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{`Channel / Group ${msg.ChannelID}`}</div>
|
||||
<div className="entity-subtitle">{`Sender ${msg.SenderUserID} · ${formatUnix(msg.Date)}`}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
|
||||
{msg.Pinned && <Badge tone="warn">{"Pinned"}</Badge>}
|
||||
{msg.Post && <Badge>{"Channel post"}</Badge>}
|
||||
<Badge>pts {msg.PTS}</Badge>
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Message ID"} value={String(msg.ID)} mono />
|
||||
<Summary label={"Channel / Group"} value={String(msg.ChannelID)} mono />
|
||||
<Summary label="From Peer" value={`${msg.FromPeerType}:${msg.FromPeerID}`} mono />
|
||||
<Summary label={"pts"} value={String(msg.PTS)} mono />
|
||||
<Summary label={"Views"} value={String(msg.ViewsCount)} />
|
||||
</div>
|
||||
<details className="raw-details">
|
||||
<summary>{"Stored rows (JSON)"}</summary>
|
||||
<div className="stacked-sections">
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Channel Message Row"} text={"channel_messages read-only snapshot"} />
|
||||
<JsonBlock value={detail.MessageJSON} />
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Channel Row"} text={"channels read-only snapshot"} />
|
||||
<JsonBlock value={detail.ChannelJSON} />
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Channel Message Row"} text={"channel_messages read-only snapshot"} />
|
||||
<JsonBlock value={detail.MessageJSON} />
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Channel Row"} text={"channels read-only snapshot"} />
|
||||
<JsonBlock value={detail.ChannelJSON} />
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Channel Update Events"} text={"durable channel_update_events"} />
|
||||
<div className="table-wrap">
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { ChevronRight, Search } from "lucide-react";
|
|||
import { useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ChannelPicker } from "../components/EntityPicker";
|
||||
import { Alert, Badge, EmptyRow, Metric, QueryPanel } from "../components/ui";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { channelKind, formatUnix } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { ChannelRow, GroupMessageListResponse } from "../types";
|
||||
|
||||
export function GroupMessagesTab({ navigate }: { navigate: Navigate }) {
|
||||
export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
|
||||
const [channel, setChannel] = useState<ChannelRow | null>(null);
|
||||
const [beforeDate, setBeforeDate] = useState("");
|
||||
const [beforeID, setBeforeID] = useState("");
|
||||
|
|
@ -52,7 +52,7 @@ export function GroupMessagesTab({ navigate }: { navigate: Navigate }) {
|
|||
const rows = data?.rows ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageFrame title={"Group Messages"} eyebrow={"Supergroup / channel messages"}>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<QueryPanel>
|
||||
<div className="message-selector-grid single">
|
||||
|
|
@ -114,6 +114,6 @@ export function GroupMessagesTab({ navigate }: { navigate: Navigate }) {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,89 +1,24 @@
|
|||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { AppBackground } from "../components/AppBackground";
|
||||
import { Alert } from "../components/ui";
|
||||
import { ThemeSwitch } from "../theme";
|
||||
import type { AdminSession, PublicBranding } from "../types";
|
||||
import type { AdminSession } from "../types";
|
||||
|
||||
export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
// Which field is showing. The password field mounts only on step 1, so
|
||||
// there is never a submit handler wired to a "confirm" or "log in" button
|
||||
// that could fire with a still-empty password field -- credentials only
|
||||
// ever reach api.login once both are on screen and this has advanced.
|
||||
const [step, setStep] = useState<0 | 1>(0);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
// Whose server this is. Fetched without a session -- the name and icon are
|
||||
// already public from owpengram-server's client endpoints -- and left null on
|
||||
// failure so the panel simply keeps its own branding.
|
||||
const [branding, setBranding] = useState<PublicBranding | null>(null);
|
||||
const [iconFailed, setIconFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.publicBranding().then(setBranding).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// preventScroll matters here: .login-wizard clips with overflow:hidden and
|
||||
// is itself a scroll container, so a plain .focus() makes the browser
|
||||
// scroll it to reveal the field -- fighting the translateX slide and
|
||||
// leaving a stray scrollLeft behind that then desyncs every later step
|
||||
// change from what the transform shows.
|
||||
if (step === 1) {
|
||||
passwordRef.current?.focus({ preventScroll: true });
|
||||
} else {
|
||||
usernameRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
const serverName = branding?.name?.trim() || "OwpenGram";
|
||||
const iconSrc = branding?.has_icon && !iconFailed ? api.publicIconURL() : "/logo.png";
|
||||
|
||||
function goToPassword() {
|
||||
if (!username.trim()) return;
|
||||
setError("");
|
||||
setStep(1);
|
||||
}
|
||||
|
||||
function goToUsername() {
|
||||
setError("");
|
||||
setStep(0);
|
||||
}
|
||||
|
||||
// The form has one submit handler regardless of step, because Enter inside
|
||||
// any of its text inputs fires it -- routing that here means the username
|
||||
// field's Enter key advances instead of submitting a login with no password.
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (step === 0) {
|
||||
goToPassword();
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
// api.login remembers the CSRF token and tells us the sign-in worked.
|
||||
const result = await api.login(secret, username);
|
||||
// The session itself is then read from /api/session rather than assembled
|
||||
// out of the login answer. The login response carries only the actor and
|
||||
// the permissions, so building a session from it silently dropped the
|
||||
// build info, the API layers and the third-party-verification flag --
|
||||
// which is why the sidebar footer was blank until the page was reloaded.
|
||||
// One endpoint decides what a session is.
|
||||
try {
|
||||
onLogin(await api.session());
|
||||
} catch {
|
||||
// Signed in, but the follow-up read failed. Falling back to what the
|
||||
// login answer does carry beats bouncing someone back to a login form
|
||||
// they have already passed; a reload fills in the rest.
|
||||
onLogin({ actor: result.actor, permissions: result.permissions ?? [] });
|
||||
}
|
||||
// The login answer carries the permission set and the CSRF token; api.login
|
||||
// remembers the token, the session state keeps the rights.
|
||||
const result = await api.login(secret);
|
||||
onLogin({ actor: result.actor, permissions: result.permissions ?? [] });
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
|
|
@ -93,74 +28,44 @@ export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => voi
|
|||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<AppBackground />
|
||||
<div className="bg-orbs" aria-hidden="true">
|
||||
<div className="bg-orb bg-orb--1" />
|
||||
<div className="bg-orb bg-orb--2" />
|
||||
<div className="bg-orb bg-orb--3" />
|
||||
</div>
|
||||
<section className="login-panel">
|
||||
<div className="login-head">
|
||||
<div className="brand brand-elevated">
|
||||
<span className="brand-mark">
|
||||
<img src={iconSrc} alt={serverName} onError={() => setIconFailed(true)} />
|
||||
</span>
|
||||
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
|
||||
<span>
|
||||
<strong>{serverName}</strong>
|
||||
<strong>OwpenGram</strong>
|
||||
<small>{"Admin Console"}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="login-head-actions">
|
||||
<ThemeSwitch />
|
||||
<span className="login-chip">{"Local access"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-copy">
|
||||
<h1>{"Operations Admin"}</h1>
|
||||
<p>{"Enter credentials to open the console."}</p>
|
||||
</div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<form className="form-stack" onSubmit={submit}>
|
||||
<div className="login-wizard">
|
||||
<div className="login-wizard-track" style={{ transform: `translateX(-${step * 100}%)` }}>
|
||||
<div className="login-wizard-step" aria-hidden={step !== 0}>
|
||||
<label>
|
||||
<span>{"Username"}</span>
|
||||
<input
|
||||
ref={usernameRef}
|
||||
type="text"
|
||||
value={username}
|
||||
autoComplete="username"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
placeholder={"login"}
|
||||
tabIndex={step === 0 ? undefined : -1}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="login-wizard-step" aria-hidden={step !== 1}>
|
||||
<label>
|
||||
<span>{"Password"}</span>
|
||||
<input
|
||||
ref={passwordRef}
|
||||
type="password"
|
||||
value={secret}
|
||||
autoComplete="current-password"
|
||||
placeholder={"password"}
|
||||
tabIndex={step === 1 ? undefined : -1}
|
||||
onChange={(event) => setSecret(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{step === 0 ? (
|
||||
<button className="btn primary full" type="submit" disabled={!username.trim()}>
|
||||
{"Next"}
|
||||
</button>
|
||||
) : (
|
||||
<div className="login-wizard-actions">
|
||||
<button className="btn icon-text" type="button" onClick={goToUsername}>
|
||||
<ArrowLeft size={15} />
|
||||
{"Back"}
|
||||
</button>
|
||||
<button className="btn primary" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 className="spin" size={15} /> : null}
|
||||
{busy ? "Logging in" : "Log in"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<label>
|
||||
<span>{"Admin password or token"}</span>
|
||||
<input
|
||||
autoFocus
|
||||
type="password"
|
||||
value={secret}
|
||||
autoComplete="current-password"
|
||||
onChange={(event) => setSecret(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn primary full" type="submit" disabled={busy}>
|
||||
{busy ? "Logging in" : "Log in"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useEffect, useState } from "react";
|
|||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { MessageView } from "../components/MessageView";
|
||||
import { formatDate, formatUnix } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { MessageDetail } from "../types";
|
||||
|
|
@ -42,46 +41,37 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
|
|||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<MessageView
|
||||
body={msg.Body}
|
||||
media={msg.Media}
|
||||
sender={`From ${msg.FromUserID}`}
|
||||
meta={`${msg.Outgoing ? "Sent to" : "Received from"} ${msg.PeerID} · ${formatUnix(msg.Date)}`}
|
||||
badges={
|
||||
<>
|
||||
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
|
||||
<Badge>{msg.Outgoing ? "Outgoing" : "Incoming"}</Badge>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{`Owner ${msg.OwnerUserID} · Peer ${msg.PeerID}`}</div>
|
||||
<div className="entity-subtitle">{`Sender ${msg.FromUserID} · ${formatUnix(msg.Date)}`}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
|
||||
<Badge>pts {msg.PTS}</Badge>
|
||||
<Badge>{msg.Outgoing ? "Outgoing" : "Incoming"}</Badge>
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Message box ID"} value={String(msg.BoxID)} mono />
|
||||
<Summary label={"Private message ID"} value={String(msg.PrivateMessageID)} mono />
|
||||
<Summary label={"Message sender"} value={String(msg.MessageSenderID)} mono />
|
||||
<Summary label={"pts"} value={String(msg.PTS)} mono />
|
||||
<Summary label={"Time"} value={formatUnix(msg.Date)} />
|
||||
</div>
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Message Box"} text={"message_boxes read-only snapshot"} />
|
||||
<JsonBlock value={detail.MessageJSON} />
|
||||
</section>
|
||||
<div className="raw-grid">
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Dialog Row"} text={"dialogs read-only snapshot"} />
|
||||
<JsonBlock value={detail.DialogJSON} />
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Private Message Row"} text={"private_messages read-only snapshot"} />
|
||||
<JsonBlock value={detail.PrivateJSON} />
|
||||
</section>
|
||||
</div>
|
||||
{/* The stored rows stay reachable, but folded: they answer "why is
|
||||
this message in this state", which is a rarer question than
|
||||
"what does it say". */}
|
||||
<details className="raw-details">
|
||||
<summary>{"Stored rows (JSON)"}</summary>
|
||||
<div className="stacked-sections">
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Message Box"} text={"message_boxes read-only snapshot"} />
|
||||
<JsonBlock value={detail.MessageJSON} />
|
||||
</section>
|
||||
<div className="raw-grid">
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Dialog Row"} text={"dialogs read-only snapshot"} />
|
||||
<JsonBlock value={detail.DialogJSON} />
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Private Message Row"} text={"private_messages read-only snapshot"} />
|
||||
<JsonBlock value={detail.PrivateJSON} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Update Events"} text={"durable user_update_events"} />
|
||||
<div className="table-wrap">
|
||||
|
|
|
|||
|
|
@ -7,9 +7,8 @@ import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../compon
|
|||
import { displayName, formatUnix, parseIDs, toInt } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountRow, MessageListResponse } from "../types";
|
||||
import { GroupMessagesTab } from "./GroupMessagesPage";
|
||||
|
||||
export function PrivateMessagesTab({ navigate }: { navigate: Navigate }) {
|
||||
export function MessagesPage({ navigate }: { navigate: Navigate }) {
|
||||
const [owner, setOwner] = useState<AccountRow | null>(null);
|
||||
const [peer, setPeer] = useState<AccountRow | null>(null);
|
||||
const [beforeDate, setBeforeDate] = useState("");
|
||||
|
|
@ -66,7 +65,7 @@ export function PrivateMessagesTab({ navigate }: { navigate: Navigate }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageFrame title={"Private Messages"} eyebrow={"Private message boxes"}>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<QueryPanel>
|
||||
<div className="message-selector-grid">
|
||||
|
|
@ -153,42 +152,6 @@ export function PrivateMessagesTab({ navigate }: { navigate: Navigate }) {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// The two message stores are one screen with two tabs rather than two sidebar
|
||||
// entries: they are the same job ("look at what was said") over different peer
|
||||
// kinds, and a nested menu made that look like two unrelated sections.
|
||||
export function MessagesPage({ navigate, tab, onTab }: {
|
||||
navigate: Navigate;
|
||||
tab: "private" | "groups";
|
||||
onTab: (tab: "private" | "groups") => void;
|
||||
}) {
|
||||
return (
|
||||
<PageFrame title={"Messages"} eyebrow={"Message boxes and channel history"}>
|
||||
<div className="tab-bar" role="tablist" aria-label={"Message sections"}>
|
||||
<button
|
||||
className={`tab-btn ${tab === "private" ? "active" : ""}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === "private"}
|
||||
onClick={() => onTab("private")}
|
||||
>
|
||||
{"Private"}
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${tab === "groups" ? "active" : ""}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === "groups"}
|
||||
onClick={() => onTab("groups")}
|
||||
>
|
||||
{"Groups and channels"}
|
||||
</button>
|
||||
</div>
|
||||
{tab === "private" ? <PrivateMessagesTab navigate={navigate} /> : <GroupMessagesTab navigate={navigate} />}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { type Navigate, type RouteState } from "../routing";
|
||||
import { AccountDetailPage } from "./AccountDetailPage";
|
||||
import { AccountsPage } from "./AccountsPage";
|
||||
|
|
@ -13,12 +12,11 @@ import { BotsPage } from "./BotsPage";
|
|||
import { BroadcastsPage } from "./BroadcastsPage";
|
||||
import { Dashboard } from "./Dashboard";
|
||||
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
|
||||
import { GroupMessagesPage } from "./GroupMessagesPage";
|
||||
import { MessageDetailPage } from "./MessageDetailPage";
|
||||
import { MessagesPage } from "./MessagesPage";
|
||||
import { StickerSetsPage } from "./StickerSetsPage";
|
||||
import { GifCatalogPage } from "./GifCatalogPage";
|
||||
import { AdminUsersPage } from "./AdminUsersPage";
|
||||
import { ServerSettingsPage } from "./ServerSettingsPage";
|
||||
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
|
||||
import { ModerationCasesPage } from "./ModerationCasesPage";
|
||||
import { StoragePage } from "./StoragePage";
|
||||
|
|
@ -29,30 +27,11 @@ import { VerificationPage } from "./VerificationPage";
|
|||
import {
|
||||
PermissionGate,
|
||||
ThirdPartyVerificationHiddenGate,
|
||||
permissionAccountsRead,
|
||||
permissionAdminsManage,
|
||||
permissionBotVerificationReview,
|
||||
permissionBotsRead,
|
||||
permissionBroadcastsRead,
|
||||
permissionChannelsRead,
|
||||
permissionContentRead,
|
||||
permissionDashboardRead,
|
||||
permissionMessagesRead,
|
||||
permissionModerationReview,
|
||||
permissionServerManage,
|
||||
permissionStorageRead,
|
||||
permissionUsernamesRead,
|
||||
permissionVerificationReview
|
||||
} from "../permissions";
|
||||
|
||||
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
|
||||
// Every section is wrapped in the right it needs. Without this the page
|
||||
// rendered, fired its request, and showed the backend's "permission X is
|
||||
// required" as a red bar over an empty table -- an error where a refusal
|
||||
// belongs. Gating here means the request is never made either.
|
||||
const gate = (permission: string, node: ReactNode) => (
|
||||
<PermissionGate navigate={navigate} permission={permission}>{node}</PermissionGate>
|
||||
);
|
||||
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
|
||||
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
|
||||
const botID = route.path.match(/^\/bots\/(\d+)$/)?.[1];
|
||||
|
|
@ -65,8 +44,8 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
const botVerificationRequestID = route.path.match(/^\/bot-verification\/(\d+)$/)?.[1];
|
||||
if (botVerificationRequestID) {
|
||||
return (
|
||||
<ThirdPartyVerificationHiddenGate navigate={navigate}>
|
||||
<PermissionGate navigate={navigate} permission={permissionBotVerificationReview}>
|
||||
<ThirdPartyVerificationHiddenGate>
|
||||
<PermissionGate permission={permissionBotVerificationReview}>
|
||||
<BotVerificationRequestPage id={botVerificationRequestID} navigate={navigate} />
|
||||
</PermissionGate>
|
||||
</ThirdPartyVerificationHiddenGate>
|
||||
|
|
@ -74,8 +53,8 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
}
|
||||
if (route.path === "/bot-verification") {
|
||||
return (
|
||||
<ThirdPartyVerificationHiddenGate navigate={navigate}>
|
||||
<PermissionGate navigate={navigate} permission={permissionBotVerificationReview}>
|
||||
<ThirdPartyVerificationHiddenGate>
|
||||
<PermissionGate permission={permissionBotVerificationReview}>
|
||||
<BotVerificationPage navigate={navigate} />
|
||||
</PermissionGate>
|
||||
</ThirdPartyVerificationHiddenGate>
|
||||
|
|
@ -86,111 +65,92 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
// itself instead of rendering an empty queue.
|
||||
if (verificationID) {
|
||||
return (
|
||||
<PermissionGate navigate={navigate} permission={permissionVerificationReview}>
|
||||
<PermissionGate permission={permissionVerificationReview}>
|
||||
<VerificationDetailPage id={verificationID} navigate={navigate} />
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
if (route.path === "/verification") {
|
||||
return (
|
||||
<PermissionGate navigate={navigate} permission={permissionVerificationReview}>
|
||||
<PermissionGate permission={permissionVerificationReview}>
|
||||
<VerificationPage navigate={navigate} />
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
if (collectibleUsernameID) {
|
||||
return gate(permissionUsernamesRead, <CollectibleUsernameDetailPage id={collectibleUsernameID} navigate={navigate} />);
|
||||
return <CollectibleUsernameDetailPage id={collectibleUsernameID} navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/collectible-usernames") {
|
||||
return gate(permissionUsernamesRead, <CollectibleUsernamesPage navigate={navigate} />);
|
||||
return <CollectibleUsernamesPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/reserved-usernames") {
|
||||
return <ReservedUsernamesPage />;
|
||||
}
|
||||
if (route.path === "/storage") {
|
||||
return gate(permissionStorageRead, <StoragePage navigate={navigate} />);
|
||||
return <StoragePage navigate={navigate} />;
|
||||
}
|
||||
if (accountID) {
|
||||
return gate(permissionAccountsRead, <AccountDetailPage id={Number(accountID)} navigate={navigate} />);
|
||||
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
|
||||
}
|
||||
if (channelID) {
|
||||
return gate(permissionChannelsRead, <ChannelDetailPage id={Number(channelID)} navigate={navigate} />);
|
||||
return <ChannelDetailPage id={Number(channelID)} navigate={navigate} />;
|
||||
}
|
||||
if (botID) {
|
||||
return gate(permissionBotsRead, <BotDetailPage id={Number(botID)} navigate={navigate} />);
|
||||
return <BotDetailPage id={Number(botID)} navigate={navigate} />;
|
||||
}
|
||||
if (moderationCaseID) {
|
||||
return gate(permissionModerationReview, <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />);
|
||||
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/accounts/shared-devices") {
|
||||
return gate(permissionAccountsRead, <SharedDevicesPage navigate={navigate} />);
|
||||
return <SharedDevicesPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/accounts") {
|
||||
return gate(permissionAccountsRead, <AccountsPage navigate={navigate} />);
|
||||
return <AccountsPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/channels") {
|
||||
return gate(permissionChannelsRead, <ChannelsPage navigate={navigate} />);
|
||||
return <ChannelsPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/bots") {
|
||||
return gate(permissionBotsRead, <BotsPage navigate={navigate} />);
|
||||
return <BotsPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/moderation") {
|
||||
return gate(permissionModerationReview, <ModerationCasesPage navigate={navigate} />);
|
||||
return <ModerationCasesPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/broadcasts") {
|
||||
return gate(permissionBroadcastsRead, <BroadcastsPage />);
|
||||
return <BroadcastsPage />;
|
||||
}
|
||||
if (route.path === "/emoji") {
|
||||
return gate(permissionContentRead, <StickerSetsPage kind="emoji" />);
|
||||
return <StickerSetsPage kind="emoji" />;
|
||||
}
|
||||
if (route.path === "/stickers") {
|
||||
return gate(permissionContentRead, <StickerSetsPage kind="stickers" />);
|
||||
return <StickerSetsPage kind="stickers" />;
|
||||
}
|
||||
if (route.path === "/gif-catalog") {
|
||||
return gate(permissionContentRead, <GifCatalogPage />);
|
||||
}
|
||||
if (route.path === "/admin-users") {
|
||||
return (
|
||||
<PermissionGate navigate={navigate} permission={permissionAdminsManage}>
|
||||
<AdminUsersPage />
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
if (route.path === "/server-settings") {
|
||||
return (
|
||||
<PermissionGate navigate={navigate} permission={permissionServerManage}>
|
||||
<ServerSettingsPage />
|
||||
</PermissionGate>
|
||||
);
|
||||
return <GifCatalogPage />;
|
||||
}
|
||||
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
|
||||
return gate(permissionMessagesRead, (
|
||||
return (
|
||||
<MessageDetailPage
|
||||
ownerUserID={Number(route.search.get("owner_user_id") || "0")}
|
||||
msgID={Number(route.search.get("msg_id") || "0")}
|
||||
navigate={navigate}
|
||||
/>
|
||||
));
|
||||
);
|
||||
}
|
||||
if (route.path === "/messages/groups/detail") {
|
||||
return gate(permissionMessagesRead, (
|
||||
return (
|
||||
<GroupMessageDetailPage
|
||||
channelID={Number(route.search.get("channel_id") || "0")}
|
||||
msgID={Number(route.search.get("msg_id") || "0")}
|
||||
navigate={navigate}
|
||||
/>
|
||||
));
|
||||
);
|
||||
}
|
||||
// Both tabs keep their own path so a link to one still opens on it -- the
|
||||
// tab is a view of /messages, not a hidden bit of component state.
|
||||
if (route.path === "/messages" || route.path === "/messages/private" || route.path === "/messages/groups") {
|
||||
return gate(permissionMessagesRead, (
|
||||
<MessagesPage
|
||||
navigate={navigate}
|
||||
tab={route.path === "/messages/groups" ? "groups" : "private"}
|
||||
onTab={(tab) => navigate(tab === "groups" ? "/messages/groups" : "/messages/private")}
|
||||
/>
|
||||
));
|
||||
if (route.path === "/messages/groups") {
|
||||
return <GroupMessagesPage navigate={navigate} />;
|
||||
}
|
||||
return gate(permissionDashboardRead, <Dashboard navigate={navigate} />);
|
||||
if (route.path === "/messages" || route.path === "/messages/private") {
|
||||
return <MessagesPage navigate={navigate} />;
|
||||
}
|
||||
return <Dashboard navigate={navigate} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,818 +0,0 @@
|
|||
import { ChevronDown, CircleCheck, CircleOff, CircleX, Database, Download, HardDrive, ImageOff, ImagePlus, Layers, Loader2, RefreshCw, Server, ShieldCheck, Trash2, Upload, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, LoadingSurface, PageFrame, SectionHead } from "../components/ui";
|
||||
import type { DockerService, EnvGroup, ServerIdentity, ServerStatus } from "../types";
|
||||
|
||||
// Server Settings: the web-panel equivalent of tui-panel/server-panel.py's
|
||||
// menu -- admin-editable server name/description/icon (served to clients
|
||||
// over /owpengram/server-info + /owpengram/server-icon), .env editing, and
|
||||
// live process/Docker status + Restart/Update. See
|
||||
// cmd/telesrv-admin/serversettings.go for the backend.
|
||||
//
|
||||
// Split into two tabs: "Settings" (identity + .env, rarely touched, no live
|
||||
// state) and "Services" (live process/container status + restart/update,
|
||||
// the operational side someone actually watches while things are moving).
|
||||
export function ServerSettingsPage() {
|
||||
const [tab, setTab] = useState<"settings" | "services">("settings");
|
||||
return (
|
||||
<PageFrame title={"Server Settings"} eyebrow={"Identity, .env, and live process/service control"}>
|
||||
<div className="tab-bar" role="tablist" aria-label={"Server Settings sections"}>
|
||||
<button className={`tab-btn ${tab === "settings" ? "active" : ""}`} type="button" role="tab" aria-selected={tab === "settings"} onClick={() => setTab("settings")}>
|
||||
{"Settings"}
|
||||
</button>
|
||||
<button className={`tab-btn ${tab === "services" ? "active" : ""}`} type="button" role="tab" aria-selected={tab === "services"} onClick={() => setTab("services")}>
|
||||
{"Services"}
|
||||
</button>
|
||||
</div>
|
||||
{tab === "settings" ? (
|
||||
<div className="stacked-sections">
|
||||
<IdentitySection />
|
||||
<LoginNotificationsSection />
|
||||
<EnvSection />
|
||||
</div>
|
||||
) : (
|
||||
<div className="stacked-sections">
|
||||
<ServicesTab />
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Identity ---------------------------------------------------------
|
||||
|
||||
function IdentitySection() {
|
||||
const [identity, setIdentity] = useState<ServerIdentity | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [iconModalOpen, setIconModalOpen] = useState(false);
|
||||
const [iconBust, setIconBust] = useState(0);
|
||||
const [iconFailed, setIconFailed] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
const info = await api.serverIdentity();
|
||||
setIdentity(info);
|
||||
setName(info.name);
|
||||
setDescription(info.description);
|
||||
setIconFailed(false);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
return (
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Server identity"} />
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{!identity ? (
|
||||
<LoadingSurface label={"Loading identity..."} />
|
||||
) : (
|
||||
<div className="card-body identity-card">
|
||||
<div className="identity-layout">
|
||||
<div className="avatar-edit-slot">
|
||||
{identity.icon_ext && !iconFailed ? (
|
||||
<img
|
||||
className="avatar-photo-img"
|
||||
src={api.serverIconURL() + `&b=${iconBust}`}
|
||||
alt=""
|
||||
style={{ width: 88, height: 88 }}
|
||||
onError={() => setIconFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="avatar-fallback server-icon-fallback" style={{ width: 88, height: 88 }}>
|
||||
<ImageOff size={26} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="icon-btn avatar-edit-btn"
|
||||
type="button"
|
||||
aria-label={"Change server icon"}
|
||||
title={"Change server icon"}
|
||||
onClick={() => setIconModalOpen(true)}
|
||||
>
|
||||
<ImagePlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="server-identity-fields">
|
||||
<label className="form-field"><span>{"Name"}</span><input value={name} maxLength={128} onChange={(event) => setName(event.target.value)} /></label>
|
||||
<label className="form-field"><span>{"Description"}</span><textarea rows={4} value={description} maxLength={512} onChange={(event) => setDescription(event.target.value)} /></label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gift-table-actions identity-save-row">
|
||||
<ActionButton
|
||||
tone="neutral"
|
||||
label={"Save identity"}
|
||||
path="/api/actions/set-server-identity"
|
||||
payload={() => ({ name, description })}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{iconModalOpen && (
|
||||
<ServerIconModal
|
||||
hasIcon={!!identity?.icon_ext}
|
||||
onClose={() => setIconModalOpen(false)}
|
||||
onDone={() => { setIconBust((n) => n + 1); setIconFailed(false); void load(); }}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Login notifications ------------------------------------------------
|
||||
|
||||
// LoginNotificationsSection edits the 777000 login-notification message's
|
||||
// per-method (phone/email) template -- a different concern from brand
|
||||
// identity above even though both live in the same identity.json (see
|
||||
// cmd/telesrv-admin/serversettings.go's handleSetWelcomeMessageTemplatesAPI
|
||||
// doc comment), so it gets its own card and its own save action.
|
||||
function LoginNotificationsSection() {
|
||||
const [identity, setIdentity] = useState<ServerIdentity | null>(null);
|
||||
const [phoneTemplate, setPhoneTemplate] = useState("");
|
||||
const [emailTemplate, setEmailTemplate] = useState("");
|
||||
const [codeTemplate, setCodeTemplate] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
const info = await api.serverIdentity();
|
||||
setIdentity(info);
|
||||
setPhoneTemplate(info.welcome_message_phone_template ?? "");
|
||||
setEmailTemplate(info.welcome_message_email_template ?? "");
|
||||
setCodeTemplate(info.login_code_message_template ?? "");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const phoneIsOverridden = phoneTemplate.trim() !== "";
|
||||
const emailIsOverridden = emailTemplate.trim() !== "";
|
||||
const codeIsOverridden = codeTemplate.trim() !== "";
|
||||
// Mirrors the server-side check in handleSetLoginCodeMessageTemplateAPI --
|
||||
// disable the save button instead of letting the operator submit a
|
||||
// template that would silently never deliver the actual OTP code.
|
||||
const codeOccurrences = (codeTemplate.match(/\{\{code\}\}/g) ?? []).length;
|
||||
const codeTemplateInvalid = codeIsOverridden && codeOccurrences !== 1;
|
||||
|
||||
return (
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Login notifications"} />
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{!identity ? (
|
||||
<LoadingSurface label={"Loading login notification templates..."} />
|
||||
) : (
|
||||
<div className="card-body">
|
||||
<p style={{ color: "var(--muted)", marginTop: 0 }}>
|
||||
{"Sent from the official system account on every completed sign-in. Use "}
|
||||
<code>{"{{server_name}}"}</code>
|
||||
{" to insert the server's configured name."}
|
||||
</p>
|
||||
<label className="form-field">
|
||||
<span>
|
||||
{"Phone sign-in template"}
|
||||
{" "}
|
||||
{phoneIsOverridden ? <span className="badge good">{"custom"}</span> : <span className="badge">{"default"}</span>}
|
||||
</span>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={phoneTemplate}
|
||||
onChange={(event) => setPhoneTemplate(event.target.value)}
|
||||
placeholder={identity.default_welcome_message_phone_template}
|
||||
/>
|
||||
</label>
|
||||
<div className="gift-table-actions">
|
||||
<ActionButton
|
||||
tone="neutral"
|
||||
compact
|
||||
label={"Reset to default"}
|
||||
path="/api/actions/set-welcome-message-templates"
|
||||
payload={() => ({ phone_template: "", email_template: emailTemplate })}
|
||||
disabled={!phoneIsOverridden}
|
||||
onDone={() => { setPhoneTemplate(""); void load(); }}
|
||||
/>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
<span>
|
||||
{"Email sign-in template"}
|
||||
{" "}
|
||||
{emailIsOverridden ? <span className="badge good">{"custom"}</span> : <span className="badge">{"default"}</span>}
|
||||
</span>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={emailTemplate}
|
||||
onChange={(event) => setEmailTemplate(event.target.value)}
|
||||
placeholder={identity.default_welcome_message_email_template}
|
||||
/>
|
||||
</label>
|
||||
<div className="gift-table-actions">
|
||||
<ActionButton
|
||||
tone="neutral"
|
||||
compact
|
||||
label={"Reset to default"}
|
||||
path="/api/actions/set-welcome-message-templates"
|
||||
payload={() => ({ phone_template: phoneTemplate, email_template: "" })}
|
||||
disabled={!emailIsOverridden}
|
||||
onDone={() => { setEmailTemplate(""); void load(); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="gift-table-actions identity-save-row">
|
||||
<ActionButton
|
||||
tone="neutral"
|
||||
label={"Save login notification templates"}
|
||||
path="/api/actions/set-welcome-message-templates"
|
||||
payload={() => ({ phone_template: phoneTemplate, email_template: emailTemplate })}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ color: "var(--muted)", marginTop: "1.5em", borderTop: "1px solid var(--line)", paddingTop: "1em" }}>
|
||||
{"Sent from the official system account with every login code (SMS and email alike). Must contain "}
|
||||
<code>{"{{code}}"}</code>
|
||||
{" exactly once -- that's where the actual code is inserted and bolded. "}
|
||||
<code>{"{{server_name}}"}</code>
|
||||
{" is optional and may appear any number of times."}
|
||||
</p>
|
||||
<label className="form-field">
|
||||
<span>
|
||||
{"Login-code message template"}
|
||||
{" "}
|
||||
{codeIsOverridden ? <span className="badge good">{"custom"}</span> : <span className="badge">{"default"}</span>}
|
||||
</span>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={codeTemplate}
|
||||
onChange={(event) => setCodeTemplate(event.target.value)}
|
||||
placeholder={identity.default_login_code_message_template}
|
||||
/>
|
||||
{codeTemplateInvalid && (
|
||||
<span style={{ color: "var(--danger-text)", fontSize: "0.85em" }}>
|
||||
{codeOccurrences === 0
|
||||
? "Must contain {{code}} exactly once -- it is currently missing."
|
||||
: `Must contain {{code}} exactly once -- it currently appears ${codeOccurrences} times.`}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="gift-table-actions">
|
||||
<ActionButton
|
||||
tone="neutral"
|
||||
compact
|
||||
label={"Reset to default"}
|
||||
path="/api/actions/set-login-code-message-template"
|
||||
payload={() => ({ template: "" })}
|
||||
disabled={!codeIsOverridden}
|
||||
onDone={() => { setCodeTemplate(""); void load(); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="gift-table-actions identity-save-row">
|
||||
<ActionButton
|
||||
tone="neutral"
|
||||
label={"Save login-code message template"}
|
||||
path="/api/actions/set-login-code-message-template"
|
||||
payload={() => ({ template: codeTemplate })}
|
||||
disabled={codeTemplateInvalid}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// autoReason skips the "why is this changing" prompt in favor of a fixed
|
||||
// reason -- for the first-run wizard, where there is no prior state to
|
||||
// justify changing away from and no one else's icon to be overwriting.
|
||||
export function ServerIconModal({ hasIcon, onClose, onDone, autoReason }: { hasIcon: boolean; onClose: () => void; onDone: () => void; autoReason?: string }) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewURL, setPreviewURL] = useState("");
|
||||
const [typedReason, setTypedReason] = useState("");
|
||||
const reason = autoReason ?? typedReason;
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
setPreviewURL("");
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewURL(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
async function submitUpload() {
|
||||
if (!file) {
|
||||
setError("Choose an image file first.");
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
setError("Please enter an operation reason");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true }));
|
||||
form.set("file", file, file.name);
|
||||
const result = await api.uploadServerIcon(form);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
onDone();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRemove() {
|
||||
if (!reason.trim()) {
|
||||
setError("Please enter an operation reason");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.action("/api/actions/remove-server-icon", { command_id: "", reason: reason.trim(), confirm: true });
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
onDone();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Change server icon"}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{"Server identity"}</div>
|
||||
<h2>{"Change server icon"}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
|
||||
<input type="file" accept=".png,.jpg,.jpeg,.webp,.gif,image/png,image/jpeg,image/webp,image/gif" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||
{previewURL ? <img className="gift-file-icon" src={previewURL} alt="" style={{ objectFit: "cover" }} /> : <ImagePlus size={22} />}
|
||||
<span className="gift-file-copy"><span className="gift-field-label">{"New icon"}</span><strong>{file ? file.name : "Choose a PNG, JPEG, WebP, or GIF image"}</strong></span>
|
||||
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
|
||||
</label>
|
||||
{autoReason === undefined && (
|
||||
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={typedReason} placeholder={"Briefly describe why the server icon is changing"} onChange={(event) => setTypedReason(event.target.value)} /></label>
|
||||
)}
|
||||
{error && <Alert>{error}</Alert>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
|
||||
{hasIcon && (
|
||||
<button className="btn danger icon-text" type="button" onClick={() => void submitRemove()} disabled={busy}>
|
||||
{busy ? <Loader2 className="spin" size={15} /> : <Trash2 size={15} />}
|
||||
{"Remove icon"}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn primary icon-text" type="button" onClick={() => void submitUpload()} disabled={busy}>
|
||||
{busy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />}
|
||||
{"Upload icon"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
// --- .env editor -----------------------------------------------------
|
||||
|
||||
function EnvSection() {
|
||||
const [groups, setGroups] = useState<EnvGroup[]>([]);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [open, setOpen] = useState<Record<string, boolean>>({});
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
const g = await api.serverEnv();
|
||||
setGroups(g);
|
||||
const next: Record<string, string> = {};
|
||||
for (const group of g) {
|
||||
for (const field of group.fields) {
|
||||
next[field.key] = field.value;
|
||||
}
|
||||
}
|
||||
setValues(next);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const fieldCount = useMemo(() => groups.reduce((sum, g) => sum + g.fields.length, 0), [groups]);
|
||||
|
||||
return (
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Environment (.env)"} text={`${fieldCount} setting(s) across ${groups.length} group(s). Changes take effect on the next Restart/Update.`} />
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="env-groups">
|
||||
{groups.map((group) => {
|
||||
const isOpen = !!open[group.title];
|
||||
return (
|
||||
<div key={group.title} className={`env-group ${isOpen ? "open" : ""}`}>
|
||||
<button
|
||||
className="env-group-toggle"
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => setOpen((prev) => ({ ...prev, [group.title]: !prev[group.title] }))}
|
||||
>
|
||||
<span className="env-group-toggle-text">
|
||||
<span className="env-group-toggle-title">{group.title}</span>
|
||||
<span className="env-group-toggle-count">{`${group.fields.length} field${group.fields.length === 1 ? "" : "s"}`}</span>
|
||||
</span>
|
||||
<ChevronDown size={16} className="env-group-chevron" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="env-group-body">
|
||||
{group.description && <p className="env-group-desc">{group.description}</p>}
|
||||
{group.fields.map((field) => (
|
||||
<label key={field.key} className="form-field env-field">
|
||||
<span className="mono">{field.key}</span>
|
||||
{field.description && <span className="env-field-desc">{field.description}</span>}
|
||||
<input
|
||||
type={field.sensitive ? "password" : "text"}
|
||||
value={values[field.key] ?? ""}
|
||||
placeholder={field.default_value}
|
||||
onChange={(event) => setValues((prev) => ({ ...prev, [field.key]: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="gift-table-actions env-save-row">
|
||||
<ActionButton
|
||||
tone="warn"
|
||||
label={"Save .env changes"}
|
||||
path="/api/actions/update-server-env"
|
||||
payload={() => ({ values })}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Services tab (live Docker + process status, restart/update) --------
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// useAdminRestartWatcher backs the "the admin panel is bouncing itself"
|
||||
// flow after Restart/Update: those actions ask owpengram-server to relaunch
|
||||
// the admin process once *it* is back up (see internal/procctl's
|
||||
// PendingAdminRestart), so from the browser's side this just means polling
|
||||
// /api/session until a *different* boot_id answers -- proof a genuinely new
|
||||
// process is up, not just that the old one is still slow -- then reloading
|
||||
// the page. A timeout surfaces as a message with a manual reload button
|
||||
// instead of spinning forever if something went wrong server-side.
|
||||
export function useAdminRestartWatcher() {
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
const cancelled = useRef(false);
|
||||
|
||||
const watch = useCallback(async (timeoutMs = 150000, options?: { beforeReload?: () => Promise<void> | void }) => {
|
||||
cancelled.current = false;
|
||||
setTimedOut(false);
|
||||
setWaiting(true);
|
||||
let baseline = "";
|
||||
try {
|
||||
baseline = (await api.session()).boot_id ?? "";
|
||||
} catch {
|
||||
// Falls through to polling anyway -- worst case it reloads on the
|
||||
// first boot_id it manages to read, which is still correct.
|
||||
}
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (cancelled.current) return;
|
||||
await sleep(1500);
|
||||
try {
|
||||
const session = await api.session();
|
||||
if (session.boot_id && session.boot_id !== baseline) {
|
||||
// beforeReload runs against the new process (this session read
|
||||
// already proved it's up) and can't fail the reload -- a reload
|
||||
// an operator is staring at a spinner for shouldn't hang on it.
|
||||
if (options?.beforeReload) {
|
||||
await Promise.resolve(options.beforeReload()).catch(() => undefined);
|
||||
}
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Expected mid-bounce: the old process is dying or the new one
|
||||
// hasn't opened its listener yet. Keep polling.
|
||||
}
|
||||
}
|
||||
setWaiting(false);
|
||||
setTimedOut(true);
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
cancelled.current = true;
|
||||
setWaiting(false);
|
||||
setTimedOut(false);
|
||||
}, []);
|
||||
|
||||
return { waiting, timedOut, watch, dismiss };
|
||||
}
|
||||
|
||||
// No detail line under the heading on purpose -- "restarting owpengram-server
|
||||
// and the admin panel" (or Update's commit count) told the operator nothing
|
||||
// they didn't already know from having just clicked Restart/Update/Finish
|
||||
// setup, and this is meant to be glanced at for a few seconds, not read.
|
||||
export function RestartOverlay({ timedOut, onDismiss }: { timedOut: boolean; onDismiss: () => void }) {
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal restart-overlay" role="dialog" aria-modal="true" aria-label={timedOut ? "Restart is taking longer than expected" : "Restarting"}>
|
||||
{timedOut ? (
|
||||
<div className="command-body restart-overlay-body">
|
||||
<div className="restart-overlay-badge warn">
|
||||
<RefreshCw size={26} />
|
||||
</div>
|
||||
<h2 className="restart-overlay-heading">{"Still restarting..."}</h2>
|
||||
<Alert>{"The admin panel did not come back within the expected time. It may still be building/restarting -- reload manually in a bit, or check the server logs."}</Alert>
|
||||
<div className="gift-table-actions restart-overlay-actions">
|
||||
<button className="btn" type="button" onClick={onDismiss}>{"Dismiss"}</button>
|
||||
<button className="btn primary" type="button" onClick={() => window.location.reload()}>{"Reload now"}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="command-body restart-overlay-body">
|
||||
<div className="restart-overlay-badge">
|
||||
<RefreshCw size={26} className="restart-overlay-spin" />
|
||||
</div>
|
||||
<h2 className="restart-overlay-heading">{"Restarting"}</h2>
|
||||
<div className="loader-bar restart-overlay-progress" />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
type LiveTone = "good" | "warn" | "danger" | "idle";
|
||||
|
||||
function liveDotIcon(tone: LiveTone) {
|
||||
switch (tone) {
|
||||
case "good": return <CircleCheck size={15} />;
|
||||
case "warn": return <Loader2 className="spin" size={15} />;
|
||||
case "danger": return <CircleX size={15} />;
|
||||
default: return <CircleOff size={15} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceCard renders one live status tile -- a Docker container or a local
|
||||
// process -- with a status pill (dot + label) and up to one detail line.
|
||||
// Shared between the Docker services grid and the process-control grid so
|
||||
// both read the same way at a glance instead of two different layouts.
|
||||
function ServiceCard({
|
||||
icon,
|
||||
name,
|
||||
tone,
|
||||
statusLabel,
|
||||
detail
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
name: string;
|
||||
tone: LiveTone;
|
||||
statusLabel: string;
|
||||
detail?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`service-card tone-${tone}`}>
|
||||
<div className="service-card-icon">{icon}</div>
|
||||
<div className="service-card-body">
|
||||
<div className="service-card-name">{name}</div>
|
||||
<div className="service-card-detail">{detail ?? " "}</div>
|
||||
</div>
|
||||
<div className="service-card-status">
|
||||
{liveDotIcon(tone)}
|
||||
<span>{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dockerServiceIcon: Record<string, React.ReactNode> = {
|
||||
postgres: <Database size={18} />,
|
||||
redis: <Layers size={18} />,
|
||||
minio: <HardDrive size={18} />
|
||||
};
|
||||
|
||||
function dockerTone(service: DockerService): LiveTone {
|
||||
const state = service.state.toLowerCase();
|
||||
const health = service.health.toLowerCase();
|
||||
if (state !== "running") return "danger";
|
||||
if (health === "unhealthy") return "danger";
|
||||
if (health === "starting") return "warn";
|
||||
return "good";
|
||||
}
|
||||
|
||||
function dockerStatusLabel(service: DockerService): string {
|
||||
const state = service.state.toLowerCase();
|
||||
if (state !== "running") return service.state || "stopped";
|
||||
if (service.health) return service.health;
|
||||
return "running";
|
||||
}
|
||||
|
||||
// Live polling cadence for the Services tab. Fast enough that a
|
||||
// Restart/Update's effect on the process/container cards feels immediate,
|
||||
// slow enough not to hammer `docker compose ps` (which shells out) every
|
||||
// couple seconds for no reason.
|
||||
const LIVE_POLL_MS = 4000;
|
||||
|
||||
// UpdateButton is a two-state control: "Check updates" (a plain git fetch +
|
||||
// commit count, no side effects) until a check finds the branch behind its
|
||||
// upstream, at which point it becomes "Update (<N>)" -- an ActionButton
|
||||
// running the real git pull + rebuild + restart through the same
|
||||
// reason/dry-run/confirm flow as Restart beside it. The check is not the
|
||||
// gate: it only reports how far behind the branch is, and pulling somebody
|
||||
// else's commits and relaunching the deployment deserves the same recorded
|
||||
// reason as every other action here. Auto-checks once on mount so the button
|
||||
// reflects reality without an operator having to click twice.
|
||||
function UpdateButton({ onUpdateStarted }: { onUpdateStarted: () => void }) {
|
||||
const [behind, setBehind] = useState<number | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const check = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const result = await api.checkServerUpdates();
|
||||
setBehind(result.commits_behind);
|
||||
setMessage(result.commits_behind > 0
|
||||
? `${result.commits_behind} new commit${result.commits_behind === 1 ? "" : "s"} pulled from GitHub.`
|
||||
: "Already up to date.");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void check(); }, [check]);
|
||||
|
||||
const commits = behind ?? 0;
|
||||
const hasUpdates = commits > 0;
|
||||
|
||||
if (hasUpdates) {
|
||||
return (
|
||||
<ActionButton
|
||||
compact
|
||||
tone="danger"
|
||||
icon={<Download size={15} />}
|
||||
label={`Update (${commits})`}
|
||||
path="/api/actions/update-server"
|
||||
payload={() => ({})}
|
||||
onDone={onUpdateStarted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="btn compact-btn icon-text"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
title={error || message || undefined}
|
||||
onClick={() => void check()}
|
||||
>
|
||||
{busy ? <Loader2 className="spin" size={15} /> : <RefreshCw size={15} />}
|
||||
{"Check updates"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ServicesTab() {
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||
const [statusError, setStatusError] = useState("");
|
||||
const [docker, setDocker] = useState<DockerService[] | null>(null);
|
||||
const [dockerError, setDockerError] = useState("");
|
||||
const restartWatcher = useAdminRestartWatcher();
|
||||
const pausedRef = useRef(false);
|
||||
pausedRef.current = restartWatcher.waiting;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (pausedRef.current) return;
|
||||
try {
|
||||
setStatus(await api.serverStatus());
|
||||
setStatusError("");
|
||||
} catch (err) {
|
||||
setStatusError(errorMessage(err));
|
||||
}
|
||||
try {
|
||||
setDocker(await api.dockerStatus());
|
||||
setDockerError("");
|
||||
} catch (err) {
|
||||
setDockerError(errorMessage(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const id = window.setInterval(() => void load(), LIVE_POLL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const loading = status === null && docker === null && !statusError && !dockerError;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={"Services"}
|
||||
action={
|
||||
<div className="services-header-actions">
|
||||
<UpdateButton onUpdateStarted={() => void restartWatcher.watch()} />
|
||||
<ActionButton
|
||||
compact
|
||||
tone="primary"
|
||||
label={"Restart"}
|
||||
path="/api/actions/restart-server"
|
||||
payload={() => ({})}
|
||||
onDone={() => void restartWatcher.watch()}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{statusError && <Alert>{statusError}</Alert>}
|
||||
{dockerError && <Alert>{dockerError}</Alert>}
|
||||
{loading ? (
|
||||
<LoadingSurface label={"Loading service status..."} />
|
||||
) : (
|
||||
<div className="service-grid">
|
||||
{status && (
|
||||
<>
|
||||
<ServiceCard
|
||||
icon={<Server size={18} />}
|
||||
name={"Server"}
|
||||
tone={status.ServerAlive ? "good" : "danger"}
|
||||
statusLabel={status.ServerAlive ? "running" : "stopped"}
|
||||
detail={status.ServerAlive ? `pid ${status.ServerPID}` : undefined}
|
||||
/>
|
||||
<ServiceCard
|
||||
icon={<ShieldCheck size={18} />}
|
||||
name={"admin panel"}
|
||||
tone={status.AdminAlive ? "good" : "danger"}
|
||||
statusLabel={status.AdminAlive ? "running" : "stopped"}
|
||||
detail={status.AdminAlive ? `pid ${status.AdminPID}` : undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{docker?.map((service) => (
|
||||
<ServiceCard
|
||||
key={service.name}
|
||||
icon={dockerServiceIcon[service.name] ?? <Database size={18} />}
|
||||
name={service.name}
|
||||
tone={dockerTone(service)}
|
||||
statusLabel={dockerStatusLabel(service)}
|
||||
detail={service.state}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{restartWatcher.waiting && <RestartOverlay timedOut={false} onDismiss={restartWatcher.dismiss} />}
|
||||
{restartWatcher.timedOut && <RestartOverlay timedOut={true} onDismiss={restartWatcher.dismiss} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,74 +1,24 @@
|
|||
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, ChevronRight, Loader2, RefreshCw, Search, Settings2, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronDown, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { cacheGet, cacheKeys, cacheSet } from "../lib/cache";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, EmptyRow, LoadingRow, Metric, PageFrame, QueryPanel, SectionHead } from "../components/ui";
|
||||
import { Alert, EmptyRow, Metric, PageFrame } from "../components/ui";
|
||||
import { displayUsername, formatBytes, formatQuantity } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountStorageRow, StorageStatsResponse } from "../types";
|
||||
|
||||
type StorageSortKey = "user_id" | "username" | "bytes" | "files";
|
||||
|
||||
// SortableHeader is a <th> that toggles ascending/descending on click and
|
||||
// shows which column (and direction) is currently active -- there's no
|
||||
// existing sortable-table convention elsewhere in this admin panel to
|
||||
// mirror, so this is a small, self-contained one for this page.
|
||||
function SortableHeader({
|
||||
label,
|
||||
sortKey,
|
||||
activeKey,
|
||||
desc,
|
||||
onSort
|
||||
}: {
|
||||
label: string;
|
||||
sortKey: StorageSortKey;
|
||||
activeKey: StorageSortKey;
|
||||
desc: boolean;
|
||||
onSort: (key: StorageSortKey) => void;
|
||||
}) {
|
||||
const active = sortKey === activeKey;
|
||||
return (
|
||||
<th>
|
||||
<button type="button" className="sort-header" onClick={() => onSort(sortKey)}>
|
||||
{label}
|
||||
{active ? (desc ? <ArrowDown size={13} /> : <ArrowUp size={13} />) : <ArrowUpDown size={13} className="sort-header-idle" />}
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageOverviewTab({ navigate }: { navigate: Navigate }) {
|
||||
// Seeded from this session's last figures, so returning to Storage opens on
|
||||
// numbers rather than on shimmering placeholders. loadStats still runs.
|
||||
const [stats, setStats] = useState<StorageStatsResponse | null>(
|
||||
() => cacheGet<StorageStatsResponse>(cacheKeys.storageStats) ?? null
|
||||
);
|
||||
// Tracked separately from `error`: loadStats deliberately swallows its
|
||||
// failure so it can't block the account list, which would otherwise leave
|
||||
// the metric skeletons shimmering forever on a stats-only outage.
|
||||
const [statsFailed, setStatsFailed] = useState(false);
|
||||
const [rows, setRows] = useState<AccountStorageRow[]>(
|
||||
() => cacheGet<AccountStorageRow[]>(cacheKeys.storageAccounts) ?? []
|
||||
);
|
||||
export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
|
||||
const [stats, setStats] = useState<StorageStatsResponse | null>(null);
|
||||
const [rows, setRows] = useState<AccountStorageRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [sortKey, setSortKey] = useState<StorageSortKey>("bytes");
|
||||
const [sortDesc, setSortDesc] = useState(true);
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const next = await api.storageStats();
|
||||
cacheSet(cacheKeys.storageStats, next);
|
||||
setStats(next);
|
||||
setStatsFailed(false);
|
||||
setStats(await api.storageStats());
|
||||
} catch {
|
||||
// Stats are a header nicety; a failure here shouldn't block the list.
|
||||
setStatsFailed(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,26 +26,11 @@ function StorageOverviewTab({ navigate }: { navigate: Navigate }) {
|
|||
setBusy(true);
|
||||
setError("");
|
||||
const at = next ? offset : 0;
|
||||
const params = new URLSearchParams({
|
||||
limit: "50",
|
||||
offset: String(at),
|
||||
sort: sortKey,
|
||||
order: sortDesc ? "desc" : "asc"
|
||||
});
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
const params = new URLSearchParams({ limit: "50", offset: String(at) });
|
||||
try {
|
||||
const result = await api.storageAccounts(params);
|
||||
const page = result.rows ?? [];
|
||||
setRows((current) => {
|
||||
const merged = next ? [...current, ...page] : page;
|
||||
// Only the first page is worth keeping: it is what the screen opens on,
|
||||
// and caching an appended list would restore a scroll position nobody
|
||||
// asked for.
|
||||
if (!next) {
|
||||
cacheSet(cacheKeys.storageAccounts, merged);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
setRows((current) => (next ? [...current, ...page] : page));
|
||||
setOffset(result.next_offset);
|
||||
setHasMore(Boolean(result.has_more));
|
||||
} catch (err) {
|
||||
|
|
@ -113,16 +48,7 @@ function StorageOverviewTab({ navigate }: { navigate: Navigate }) {
|
|||
useEffect(() => {
|
||||
refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sortKey, sortDesc]);
|
||||
|
||||
function toggleSort(key: StorageSortKey) {
|
||||
if (key === sortKey) {
|
||||
setSortDesc((current) => !current);
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDesc(true);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Physical is what actually consumes disk/S3 (deduplicated); logical is the
|
||||
// sum of what the per-account table below adds up to. They legitimately
|
||||
|
|
@ -130,69 +56,41 @@ function StorageOverviewTab({ navigate }: { navigate: Navigate }) {
|
|||
const dedupBytes = stats ? Math.max(0, Number(stats.LogicalBytes) - Number(stats.PhysicalBytes)) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageFrame
|
||||
title={"Storage"}
|
||||
eyebrow={"Media / Storage usage"}
|
||||
actions={
|
||||
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric
|
||||
label={"Physical usage (on disk / S3)"}
|
||||
value={stats ? formatBytes(stats.PhysicalBytes) : "-"}
|
||||
loading={!stats && !statsFailed}
|
||||
/>
|
||||
<Metric
|
||||
label={"Logical usage (sum per account)"}
|
||||
value={stats ? formatBytes(stats.LogicalBytes) : "-"}
|
||||
loading={!stats && !statsFailed}
|
||||
/>
|
||||
<Metric
|
||||
label={"Saved by dedup"}
|
||||
// Derived from stats, so before they land this is a placeholder 0 --
|
||||
// it has to shimmer like the rest rather than assert "0 B", which
|
||||
// reads as a real measurement of nothing saved.
|
||||
value={formatBytes(String(dedupBytes))}
|
||||
loading={!stats && !statsFailed}
|
||||
tone={dedupBytes > 0 ? "good" : "neutral"}
|
||||
/>
|
||||
<Metric label={"Backend"} value={stats?.BackendKind ?? "-"} loading={!stats && !statsFailed} />
|
||||
<Metric label={"Physical usage (on disk / S3)"} value={stats ? formatBytes(stats.PhysicalBytes) : "-"} />
|
||||
<Metric label={"Logical usage (sum per account)"} value={stats ? formatBytes(stats.LogicalBytes) : "-"} />
|
||||
<Metric label={"Saved by dedup"} value={formatBytes(String(dedupBytes))} tone={dedupBytes > 0 ? "good" : "neutral"} />
|
||||
<Metric label={"Backend"} value={stats?.BackendKind ?? "-"} />
|
||||
</div>
|
||||
<div className="metric-row">
|
||||
<Metric label={"Documents"} value={stats ? formatQuantity(stats.DocumentCount) : "-"} loading={!stats && !statsFailed} />
|
||||
<Metric label={"Photos"} value={stats ? formatQuantity(stats.PhotoCount) : "-"} loading={!stats && !statsFailed} />
|
||||
<Metric label={"Documents"} value={stats ? formatQuantity(stats.DocumentCount) : "-"} />
|
||||
<Metric label={"Photos"} value={stats ? formatQuantity(stats.PhotoCount) : "-"} />
|
||||
<Metric label={"Accounts with media"} value={stats ? formatQuantity(stats.AccountCount) : "-"} />
|
||||
<Metric
|
||||
label={"Accounts with media"}
|
||||
value={stats ? formatQuantity(stats.AccountCount) : "-"}
|
||||
loading={!stats && !statsFailed}
|
||||
/>
|
||||
<Metric
|
||||
label={"System/bundled content"}
|
||||
value={stats ? formatBytes(stats.SystemBytes) : "-"}
|
||||
loading={!stats && !statsFailed}
|
||||
label={"Unattributed"}
|
||||
value={stats ? formatBytes(stats.UnattributedBytes) : "-"}
|
||||
tone={stats && Number(stats.UnattributedBytes) > 0 ? "warn" : "neutral"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void loadAccounts(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"User ID / username / name"} />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader label={"User ID"} sortKey="user_id" activeKey={sortKey} desc={sortDesc} onSort={toggleSort} />
|
||||
<SortableHeader label={"Account"} sortKey="username" activeKey={sortKey} desc={sortDesc} onSort={toggleSort} />
|
||||
<SortableHeader label={"Storage used"} sortKey="bytes" activeKey={sortKey} desc={sortDesc} onSort={toggleSort} />
|
||||
<SortableHeader label={"Files"} sortKey="files" activeKey={sortKey} desc={sortDesc} onSort={toggleSort} />
|
||||
<th></th>
|
||||
<th>{"User ID"}</th>
|
||||
<th>{"Account"}</th>
|
||||
<th>{"Storage used"}</th>
|
||||
<th>{"Files"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -202,10 +100,9 @@ function StorageOverviewTab({ navigate }: { navigate: Navigate }) {
|
|||
<td>{displayUsername(row.Username) || row.FirstName || "-"}</td>
|
||||
<td className="mono">{formatBytes(row.Bytes)}</td>
|
||||
<td className="mono">{formatQuantity(row.FileCount)}</td>
|
||||
<td><button className="row-link" type="button" onClick={() => navigate(`/accounts/${row.UserID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (busy ? <LoadingRow colSpan={5} /> : <EmptyRow colSpan={5} />)}
|
||||
{rows.length === 0 && <EmptyRow colSpan={4} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -216,654 +113,6 @@ function StorageOverviewTab({ navigate }: { navigate: Navigate }) {
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function StoragePage({ navigate }: { navigate: Navigate }) {
|
||||
const [tab, setTab] = useState<"overview" | "limits">("overview");
|
||||
return (
|
||||
<PageFrame title={"Storage"} eyebrow={"Media / Storage usage"}>
|
||||
<div className="tab-bar" role="tablist" aria-label={"Storage sections"}>
|
||||
<button className={`tab-btn ${tab === "overview" ? "active" : ""}`} type="button" role="tab" aria-selected={tab === "overview"} onClick={() => setTab("overview")}>
|
||||
{"Overview"}
|
||||
</button>
|
||||
<button className={`tab-btn ${tab === "limits" ? "active" : ""}`} type="button" role="tab" aria-selected={tab === "limits"} onClick={() => setTab("limits")}>
|
||||
{"Limits & Retention"}
|
||||
</button>
|
||||
</div>
|
||||
{tab === "overview" ? <StorageOverviewTab navigate={navigate} /> : <LimitsRetentionSection />}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Limits & Retention --------------------------------------------------
|
||||
//
|
||||
// Edits a handful of TELESRV_STORAGE_* keys through the existing generic
|
||||
// .env editor endpoints (GET /api/server/env, POST
|
||||
// /api/actions/update-server-env -- see cmd/telesrv-admin/serversettings.go
|
||||
// and ServerSettingsPage.tsx's EnvSection, which this mirrors for its
|
||||
// save/reload flow), but with friendly units instead of raw key=value text:
|
||||
// GB inputs for byte budgets, a mode dropdown + day count for retention.
|
||||
|
||||
const STORAGE_ENV_KEYS = {
|
||||
blobBackend: "TELESRV_BLOB_BACKEND",
|
||||
maxTotal: "TELESRV_STORAGE_MAX_TOTAL_BYTES",
|
||||
minFree: "TELESRV_STORAGE_MIN_FREE_BYTES",
|
||||
maxUploadFile: "TELESRV_STORAGE_MAX_UPLOAD_FILE_BYTES",
|
||||
retentionMode: "TELESRV_STORAGE_RETENTION_MODE",
|
||||
retentionMaxAge: "TELESRV_STORAGE_RETENTION_MAX_AGE",
|
||||
evictionEnable: "TELESRV_STORAGE_EVICTION_ENABLE"
|
||||
} as const;
|
||||
|
||||
// Per-category retention age overrides -- each optional, empty/0 means
|
||||
// "inherit the shared Retention age" above. Order matches the admin UI list.
|
||||
const CATEGORY_AGE_FIELDS: { key: string; envKey: string; label: string }[] = [
|
||||
{ key: "photo", envKey: "TELESRV_STORAGE_RETENTION_MAX_AGE_PHOTO", label: "Photo" },
|
||||
{ key: "video", envKey: "TELESRV_STORAGE_RETENTION_MAX_AGE_VIDEO", label: "Video" },
|
||||
{ key: "round_video", envKey: "TELESRV_STORAGE_RETENTION_MAX_AGE_ROUND_VIDEO", label: "Round video (video message)" },
|
||||
{ key: "gif", envKey: "TELESRV_STORAGE_RETENTION_MAX_AGE_GIF", label: "GIF" },
|
||||
{ key: "music", envKey: "TELESRV_STORAGE_RETENTION_MAX_AGE_MUSIC", label: "Music" },
|
||||
{ key: "voice", envKey: "TELESRV_STORAGE_RETENTION_MAX_AGE_VOICE", label: "Voice message" },
|
||||
{ key: "file", envKey: "TELESRV_STORAGE_RETENTION_MAX_AGE_FILE", label: "File" },
|
||||
{ key: "avatar", envKey: "TELESRV_STORAGE_RETENTION_MAX_AGE_AVATAR", label: "Avatar" }
|
||||
];
|
||||
|
||||
// DANGER_ZONE_CATEGORY_KEYS marks the two CATEGORY_AGE_FIELDS/manual-purge
|
||||
// categories that reach beyond ordinary per-user media: Avatar also covers
|
||||
// the built-in system bots' own profile photos, and GIF also covers the
|
||||
// server's bundled GIF catalog (@gif). CategoryRetentionModal and
|
||||
// ManualPurgeStorageModal both split these into their own boxed "Danger
|
||||
// zone" section instead of listing them alongside Photo/Video/Music/etc.
|
||||
const DANGER_ZONE_CATEGORY_KEYS = new Set(["gif", "avatar"]);
|
||||
|
||||
// Mirrors internal/app/files.MaxUploadPartBytes * MaxUploadParts -- the
|
||||
// protocol's own upload-part-count ceiling that TELESRV_STORAGE_MAX_UPLOAD_FILE_BYTES
|
||||
// can never legally exceed (internal/config validates this server-side too;
|
||||
// this is just an early, friendlier warning in the form).
|
||||
const PROTOCOL_UPLOAD_CEILING_BYTES = 524288 * 8000;
|
||||
|
||||
const BYTE_UNITS: { label: string; bytes: number }[] = [
|
||||
{ label: "MB", bytes: 1024 ** 2 },
|
||||
{ label: "GB", bytes: 1024 ** 3 },
|
||||
{ label: "TB", bytes: 1024 ** 4 }
|
||||
];
|
||||
|
||||
// Max single file size can never usefully exceed the protocol's own per-file
|
||||
// ceiling (PROTOCOL_UPLOAD_CEILING_BYTES, ~4000MiB) -- so its unit picker
|
||||
// drops TB entirely (a single file will never be measured in terabytes) and
|
||||
// caps the amount at a round 4096MB/4GB (both the same byte value, since
|
||||
// these units are binary) instead of letting the input accept an arbitrarily
|
||||
// large number that Save would just reject anyway.
|
||||
const MAX_FILE_SIZE_UNITS = BYTE_UNITS.filter((u) => u.label !== "TB");
|
||||
const MAX_FILE_SIZE_CAP_BYTES = 4 * 1024 ** 3;
|
||||
|
||||
function bestByteUnit(bytes: number, units: { label: string; bytes: number }[] = BYTE_UNITS): { label: string; bytes: number } {
|
||||
for (let i = units.length - 1; i >= 0; i--) {
|
||||
if (bytes >= units[i].bytes) return units[i];
|
||||
}
|
||||
return units[Math.min(1, units.length - 1)]; // default to GB (or the 2nd unit) for small/zero values
|
||||
}
|
||||
|
||||
// parseDurationMinutes reads a Go-style duration string (e.g. "720h", "90m",
|
||||
// "1h30m") and returns the total as minutes. Unrecognized/empty input is 0.
|
||||
function parseDurationMinutes(value: string): number {
|
||||
let totalSeconds = 0;
|
||||
const re = /(\d+(?:\.\d+)?)\s*(h|m|s)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(value)) !== null) {
|
||||
const amount = parseFloat(match[1]);
|
||||
const unit = match[2];
|
||||
totalSeconds += unit === "h" ? amount * 3600 : unit === "m" ? amount * 60 : amount;
|
||||
}
|
||||
return totalSeconds / 60;
|
||||
}
|
||||
|
||||
const DURATION_UNITS: { label: string; minutes: number }[] = [
|
||||
{ label: "Minutes", minutes: 1 },
|
||||
{ label: "Hours", minutes: 60 },
|
||||
{ label: "Days", minutes: 1440 }
|
||||
];
|
||||
|
||||
function bestDurationUnit(minutes: number): { label: string; minutes: number } {
|
||||
for (let i = DURATION_UNITS.length - 1; i >= 0; i--) {
|
||||
if (minutes >= DURATION_UNITS[i].minutes) return DURATION_UNITS[i];
|
||||
}
|
||||
return DURATION_UNITS[0];
|
||||
}
|
||||
|
||||
// DurationField edits one duration env value (stored as total minutes) as a
|
||||
// friendly "amount + unit" pair, letting TTL be set in minutes, hours, or
|
||||
// days rather than being locked to whole days.
|
||||
function DurationField({
|
||||
label,
|
||||
help,
|
||||
minutes,
|
||||
disabled,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
help: string;
|
||||
minutes: string;
|
||||
disabled?: boolean;
|
||||
onChange: (minutes: string) => void;
|
||||
}) {
|
||||
const totalMinutes = Number(minutes || "0");
|
||||
const [unitLabel, setUnitLabel] = useState(() => bestDurationUnit(totalMinutes).label);
|
||||
const unit = DURATION_UNITS.find((u) => u.label === unitLabel) ?? DURATION_UNITS[2];
|
||||
// 0 is a real, distinct value here (e.g. the shared Retention age
|
||||
// deliberately set to 0 to disable the default sweep) -- unlike
|
||||
// ByteSizeField's "0/empty = Unlimited" convention, this must not collapse
|
||||
// to a blank input, or there'd be no way to tell "0" from "not typed yet".
|
||||
const amount = Number.isFinite(totalMinutes) ? totalMinutes / unit.minutes : NaN;
|
||||
|
||||
function handleAmountChange(raw: string) {
|
||||
if (!raw.trim()) {
|
||||
onChange("0");
|
||||
return;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
onChange("0");
|
||||
return;
|
||||
}
|
||||
if (parsed === 0) {
|
||||
onChange("0");
|
||||
return;
|
||||
}
|
||||
onChange(String(Math.max(1, Math.round(parsed * unit.minutes))));
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="duration-field">
|
||||
<span>{label}</span>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={Number.isNaN(amount) ? "" : amount}
|
||||
placeholder={"0"}
|
||||
disabled={disabled}
|
||||
onChange={(event) => handleAmountChange(event.target.value)}
|
||||
/>
|
||||
<select value={unitLabel} disabled={disabled} onChange={(event) => setUnitLabel(event.target.value)} style={{ maxWidth: 100 }}>
|
||||
{DURATION_UNITS.map((u) => (
|
||||
<option key={u.label} value={u.label}>{u.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<span className="env-field-desc">{help}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ByteSizeField edits one byte-count env value as a friendly
|
||||
// "amount + unit" pair. bytes is the raw byte count as a string (what the
|
||||
// backend stores); an empty/zero value displays as "Unlimited". units/maxBytes
|
||||
// let a caller narrow this down for a field with its own hard ceiling (e.g.
|
||||
// Max single file size, capped at the protocol's own ~4GB per-file limit --
|
||||
// there's no point offering TB there, or letting MB/GB amounts go past it).
|
||||
function ByteSizeField({
|
||||
label,
|
||||
help,
|
||||
bytes,
|
||||
onChange,
|
||||
units = BYTE_UNITS,
|
||||
maxBytes
|
||||
}: {
|
||||
label: string;
|
||||
help: string;
|
||||
bytes: string;
|
||||
onChange: (bytes: string) => void;
|
||||
units?: { label: string; bytes: number }[];
|
||||
maxBytes?: number;
|
||||
}) {
|
||||
const numericBytes = Number(bytes || "0");
|
||||
const [unitLabel, setUnitLabel] = useState(() => bestByteUnit(numericBytes, units).label);
|
||||
const unit = units.find((u) => u.label === unitLabel) ?? units[0];
|
||||
const amount = numericBytes > 0 ? numericBytes / unit.bytes : NaN;
|
||||
|
||||
function handleAmountChange(raw: string) {
|
||||
const parsed = Number(raw);
|
||||
if (!raw.trim() || Number.isNaN(parsed) || parsed <= 0) {
|
||||
onChange("0");
|
||||
return;
|
||||
}
|
||||
let value = Math.round(parsed * unit.bytes);
|
||||
if (maxBytes && value > maxBytes) value = maxBytes;
|
||||
onChange(String(value));
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="duration-field">
|
||||
<span>{label}</span>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={maxBytes ? maxBytes / unit.bytes : undefined}
|
||||
step="any"
|
||||
value={Number.isNaN(amount) ? "" : amount}
|
||||
placeholder={"Unlimited"}
|
||||
onChange={(event) => handleAmountChange(event.target.value)}
|
||||
/>
|
||||
<select value={unitLabel} onChange={(event) => setUnitLabel(event.target.value)} style={{ maxWidth: 90 }}>
|
||||
{units.map((u) => (
|
||||
<option key={u.label} value={u.label}>{u.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<span className="env-field-desc">{help}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// CategoryRetentionModal is a focused editor for the per-category age
|
||||
// overrides -- pulled out of the main Limits & Retention flow (which was
|
||||
// getting crowded with 8 extra duration fields) into its own dialog, following
|
||||
// the same modal-backdrop/command-modal pattern as MintCollectibleUsernameModal.
|
||||
// It edits the same categoryAgeMinutes state the parent already owns; there's
|
||||
// no separate save here, just Close -- the one shared "Save limits &
|
||||
// retention settings" button below still covers these values too.
|
||||
function CategoryRetentionModal({
|
||||
categoryAgeMinutes,
|
||||
onChange,
|
||||
disabled,
|
||||
onClose
|
||||
}: {
|
||||
categoryAgeMinutes: Record<string, string>;
|
||||
onChange: (key: string, minutes: string) => void;
|
||||
disabled: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Per-category retention overrides"}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{"Limits & Retention"}</div>
|
||||
<h2>{"Per-category overrides"}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<p className="env-field-desc">
|
||||
{"Leave a category at 0 to inherit the shared Retention age. The mode switch still applies to all of them -- these only change how old that one category's media must be."}
|
||||
</p>
|
||||
{CATEGORY_AGE_FIELDS.filter((f) => !DANGER_ZONE_CATEGORY_KEYS.has(f.key)).map((field) => (
|
||||
<DurationField
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
help={"Inherits the shared Retention age when left at 0."}
|
||||
minutes={categoryAgeMinutes[field.key] || "0"}
|
||||
disabled={disabled}
|
||||
onChange={(minutes) => onChange(field.key, minutes)}
|
||||
/>
|
||||
))}
|
||||
<div className="danger-zone-box">
|
||||
<div className="danger-zone-box-title">{"Danger zone"}</div>
|
||||
<div className="danger-zone-box-body">
|
||||
<p className="env-field-desc">
|
||||
{"Also affects built-in system bot avatars and the bundled GIF catalog (@gif), not just user media."}
|
||||
</p>
|
||||
{CATEGORY_AGE_FIELDS.filter((f) => DANGER_ZONE_CATEGORY_KEYS.has(f.key)).map((field) => (
|
||||
<DurationField
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
help={"Inherits the shared Retention age when left at 0."}
|
||||
minutes={categoryAgeMinutes[field.key] || "0"}
|
||||
disabled={disabled}
|
||||
onChange={(minutes) => onChange(field.key, minutes)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn primary" type="button" onClick={onClose}>{"Close"}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
// ManualPurgeStorageModal lets an operator delete media blob bytes right now
|
||||
// by hand-picked category (and, optionally, avatars) with an optional
|
||||
// "created before" age cutoff -- the manual counterpart of the automatic
|
||||
// hard-retention sweep above. Leaving the date empty purges everything
|
||||
// matching the selected categories regardless of age. Deletion semantics are
|
||||
// identical to "hard" retention mode: only the file bytes are removed, never
|
||||
// the document/photo row, so an affected message still renders its
|
||||
// placeholder (see internal/app/files.Service.ManualPurge's doc comment).
|
||||
// Follows the same modal-backdrop/command-modal structure as
|
||||
// CategoryRetentionModal, but hands the actual mutation off to ActionButton
|
||||
// (reason + dry-run + confirm) since -- unlike the settings above -- this is
|
||||
// an immediate, irreversible delete rather than a saved .env value.
|
||||
function ManualPurgeStorageModal({ onClose }: { onClose: () => void }) {
|
||||
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
||||
const [dateValue, setDateValue] = useState("");
|
||||
|
||||
const documentFields = CATEGORY_AGE_FIELDS.filter((f) => f.key !== "avatar");
|
||||
const allSelected = CATEGORY_AGE_FIELDS.every((f) => selected[f.key]);
|
||||
|
||||
function toggle(key: string) {
|
||||
setSelected((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
const next = !allSelected;
|
||||
const nextSelected: Record<string, boolean> = {};
|
||||
for (const field of CATEGORY_AGE_FIELDS) nextSelected[field.key] = next;
|
||||
setSelected(nextSelected);
|
||||
}
|
||||
|
||||
const chosenCategories = documentFields.filter((f) => selected[f.key]).map((f) => f.key);
|
||||
const includeAvatars = Boolean(selected.avatar);
|
||||
const canSubmit = chosenCategories.length > 0 || includeAvatars;
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Manually purge storage"}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{"Limits & Retention"}</div>
|
||||
<h2>{"Manually purge storage"}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<p className="env-field-desc">
|
||||
{"Deletes the file bytes of every document/photo matching the categories below, right now -- independent of the retention mode/age configured above. The message/profile-photo itself is never deleted, only its file; a purged item starts showing as unavailable. Leave \"Created before\" empty to purge everything in the selected categories, regardless of age."}
|
||||
</p>
|
||||
<div className="attr-block">
|
||||
<label className="checkline">
|
||||
<input type="checkbox" checked={allSelected} onChange={toggleAll} />
|
||||
{" "}{"Select all"}
|
||||
</label>
|
||||
{CATEGORY_AGE_FIELDS.filter((f) => !DANGER_ZONE_CATEGORY_KEYS.has(f.key)).map((field) => (
|
||||
<label key={field.key} className="checkline">
|
||||
<input type="checkbox" checked={Boolean(selected[field.key])} onChange={() => toggle(field.key)} />
|
||||
{" "}{field.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="danger-zone-box">
|
||||
<div className="danger-zone-box-title">{"Danger zone"}</div>
|
||||
<div className="danger-zone-box-body">
|
||||
<p className="env-field-desc">
|
||||
{"Avatar also purges system bot avatars (auto-restored on next restart). GIF also purges the bundled GIF catalog (@gif) -- permanently, unless its source file is still in TELESRV_GIF_SEED_DIR."}
|
||||
</p>
|
||||
{CATEGORY_AGE_FIELDS.filter((f) => DANGER_ZONE_CATEGORY_KEYS.has(f.key)).map((field) => (
|
||||
<label key={field.key} className="checkline">
|
||||
<input type="checkbox" checked={Boolean(selected[field.key])} onChange={() => toggle(field.key)} />
|
||||
{" "}{field.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="duration-field">
|
||||
<span>{"Created before (optional)"}</span>
|
||||
<input type="date" value={dateValue} onChange={(event) => setDateValue(event.target.value)} />
|
||||
<span className="env-field-desc">{"Empty = no age limit, purge everything matching the selected categories."}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={onClose}>{"Close"}</button>
|
||||
<ActionButton
|
||||
disabled={!canSubmit}
|
||||
label={"Purge selected storage"}
|
||||
tone="danger"
|
||||
path="/api/actions/storage-manual-purge"
|
||||
payload={() => ({
|
||||
categories: chosenCategories,
|
||||
include_avatars: includeAvatars,
|
||||
created_before: dateValue ? new Date(dateValue).toISOString() : undefined
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
function LimitsRetentionSection() {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [initial, setInitial] = useState<Record<string, string>>({});
|
||||
const [blobBackend, setBlobBackend] = useState("s3");
|
||||
const [maxTotalBytes, setMaxTotalBytes] = useState("0");
|
||||
const [minFreeBytes, setMinFreeBytes] = useState("0");
|
||||
const [maxUploadFileBytes, setMaxUploadFileBytes] = useState("0");
|
||||
const [retentionMode, setRetentionMode] = useState("off");
|
||||
const [retentionAgeMinutes, setRetentionAgeMinutes] = useState("43200");
|
||||
const [categoryAgeMinutes, setCategoryAgeMinutes] = useState<Record<string, string>>({});
|
||||
const [categoryModalOpen, setCategoryModalOpen] = useState(false);
|
||||
const [evictionEnable, setEvictionEnable] = useState(false);
|
||||
const [manualPurgeOpen, setManualPurgeOpen] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
const groups = await api.serverEnv();
|
||||
const values: Record<string, string> = {};
|
||||
for (const group of groups) {
|
||||
for (const field of group.fields) {
|
||||
values[field.key] = field.value || field.default_value || "";
|
||||
}
|
||||
}
|
||||
setInitial(values);
|
||||
setBlobBackend((values[STORAGE_ENV_KEYS.blobBackend] || "s3").trim().toLowerCase());
|
||||
setMaxTotalBytes(values[STORAGE_ENV_KEYS.maxTotal] || "0");
|
||||
setMinFreeBytes(values[STORAGE_ENV_KEYS.minFree] || "0");
|
||||
setMaxUploadFileBytes(values[STORAGE_ENV_KEYS.maxUploadFile] || "0");
|
||||
const mode = (values[STORAGE_ENV_KEYS.retentionMode] || "off").trim().toLowerCase();
|
||||
setRetentionMode(mode === "orphan" || mode === "hard" ? mode : "off");
|
||||
// "720h" is only a stand-in for a genuinely UNSET value (empty string,
|
||||
// e.g. a fresh install) -- an explicit "0m" is a real, meaningful
|
||||
// setting (retention disabled by default, relying purely on
|
||||
// Per-category overrides) and must display as 0, not silently get
|
||||
// coerced back to the 30-day default the moment this page reloads.
|
||||
const rawRetentionMaxAge = values[STORAGE_ENV_KEYS.retentionMaxAge];
|
||||
const mins = parseDurationMinutes(rawRetentionMaxAge || "720h");
|
||||
setRetentionAgeMinutes(String(Math.max(0, Math.round(mins))));
|
||||
const nextCategoryAges: Record<string, string> = {};
|
||||
for (const field of CATEGORY_AGE_FIELDS) {
|
||||
const raw = values[field.envKey] || "";
|
||||
const categoryMins = parseDurationMinutes(raw);
|
||||
nextCategoryAges[field.key] = categoryMins > 0 ? String(Math.round(categoryMins)) : "0";
|
||||
}
|
||||
setCategoryAgeMinutes(nextCategoryAges);
|
||||
setEvictionEnable((values[STORAGE_ENV_KEYS.evictionEnable] || "false").trim().toLowerCase() === "true");
|
||||
setLoaded(true);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
// Only the age is re-encoded from its friendly "days" input back to a Go
|
||||
// duration string; mode/off keeps whatever TELESRV_STORAGE_RETENTION_MAX_AGE
|
||||
// already was rather than clobbering it with a throwaway placeholder.
|
||||
const pendingValues = useMemo(() => {
|
||||
const next: Record<string, string> = {
|
||||
[STORAGE_ENV_KEYS.maxTotal]: maxTotalBytes || "0",
|
||||
[STORAGE_ENV_KEYS.minFree]: minFreeBytes || "0",
|
||||
[STORAGE_ENV_KEYS.maxUploadFile]: maxUploadFileBytes || "0",
|
||||
[STORAGE_ENV_KEYS.retentionMode]: retentionMode,
|
||||
[STORAGE_ENV_KEYS.retentionMaxAge]: retentionMode === "off"
|
||||
? (initial[STORAGE_ENV_KEYS.retentionMaxAge] || "720h")
|
||||
// 0 is a legitimate value here: "no sweep by default", left for
|
||||
// per-category overrides below to opt specific categories in. Don't
|
||||
// clamp it up to a minimum of 1 -- that used to make it impossible to
|
||||
// ever save a 0 global age.
|
||||
: `${Math.max(0, Math.round(Number(retentionAgeMinutes || "0")))}m`,
|
||||
[STORAGE_ENV_KEYS.evictionEnable]: evictionEnable ? "true" : "false"
|
||||
};
|
||||
// Per-category overrides: a field left at "inherit global" (0) saves as
|
||||
// empty rather than a redundant explicit duration, matching the "unset
|
||||
// means inherit" contract on the server side.
|
||||
for (const field of CATEGORY_AGE_FIELDS) {
|
||||
const mins = Math.round(Number(categoryAgeMinutes[field.key] || "0"));
|
||||
next[field.envKey] = mins > 0 ? `${mins}m` : "";
|
||||
}
|
||||
const changed: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
if ((initial[key] ?? "") !== value) changed[key] = value;
|
||||
}
|
||||
return changed;
|
||||
}, [maxTotalBytes, minFreeBytes, maxUploadFileBytes, retentionMode, retentionAgeMinutes, categoryAgeMinutes, evictionEnable, initial]);
|
||||
|
||||
const hasChanges = Object.keys(pendingValues).length > 0;
|
||||
const uploadCeilingExceeded = Number(maxUploadFileBytes || "0") > PROTOCOL_UPLOAD_CEILING_BYTES;
|
||||
|
||||
return (
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={"Limits & Retention"}
|
||||
text={"Server-wide storage budget, per-file upload cap, and automatic media cleanup. Saved to .env -- takes effect on the next Restart/Update."}
|
||||
/>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{!loaded ? (
|
||||
<p style={{ color: "var(--muted)" }}>{"Loading current settings..."}</p>
|
||||
) : (
|
||||
<div className="card-body">
|
||||
<div className="action-groups">
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Size limits"} />
|
||||
<div className="card-body">
|
||||
<div className="attr-block">
|
||||
<ByteSizeField
|
||||
label={"Max total storage budget"}
|
||||
help={"Reject new uploads once total tracked blob bytes would exceed this. Empty/0 = unlimited."}
|
||||
bytes={maxTotalBytes}
|
||||
onChange={setMaxTotalBytes}
|
||||
/>
|
||||
{blobBackend === "localfs" && (
|
||||
<ByteSizeField
|
||||
label={"Min free space guard"}
|
||||
help={"localfs backend only: reject new uploads once real free disk space falls below this. Empty/0 disables the check."}
|
||||
bytes={minFreeBytes}
|
||||
onChange={setMinFreeBytes}
|
||||
/>
|
||||
)}
|
||||
<ByteSizeField
|
||||
label={"Max single file size"}
|
||||
help={"Reject a single upload once its total assembled size exceeds this. Empty/0 = unlimited, bounded only by the protocol's own ~4GB per-file ceiling."}
|
||||
bytes={maxUploadFileBytes}
|
||||
onChange={setMaxUploadFileBytes}
|
||||
units={MAX_FILE_SIZE_UNITS}
|
||||
maxBytes={MAX_FILE_SIZE_CAP_BYTES}
|
||||
/>
|
||||
{uploadCeilingExceeded && (
|
||||
<Alert>{"This exceeds the protocol's own ~4GB upload ceiling and will be refused when the server restarts."}</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={"Retention"}
|
||||
action={
|
||||
<button className="btn compact-btn icon-text" type="button" onClick={() => setCategoryModalOpen(true)}>
|
||||
<Settings2 size={14} /> {"Per-category..."}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="card-body">
|
||||
<div className="attr-block">
|
||||
<label className="duration-field">
|
||||
<span>{"Retention mode"}</span>
|
||||
<select value={retentionMode} onChange={(event) => setRetentionMode(event.target.value)}>
|
||||
<option value="off">{"Off"}</option>
|
||||
<option value="orphan">{"Delete once no longer used (safe)"}</option>
|
||||
<option value="hard">{"Delete after a fixed time, even if still in use"}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="env-field-desc">
|
||||
{retentionMode === "off" &&
|
||||
"No storage sweep runs; nothing is auto-deleted. Storage usage is still tracked and shown above either way."}
|
||||
{retentionMode === "orphan" &&
|
||||
"Safe: a document or photo's file is deleted only once it is no longer referenced by any message, profile photo, or sticker set. Media still visible in a conversation is never touched, regardless of age."}
|
||||
{retentionMode === "hard" &&
|
||||
"Irreversible and aggressive: a document or photo's file bytes are deleted once old enough, REGARDLESS of whether a message still references it. Old media in active conversations will start showing as unavailable once purged -- only the file is removed, the message itself keeps rendering its placeholder (name, size, thumbnail)."}
|
||||
</p>
|
||||
<DurationField
|
||||
label={"Retention age"}
|
||||
help={
|
||||
(retentionMode === "hard"
|
||||
? "How old the media itself must be, counted from when it was uploaded, before its bytes are purged."
|
||||
: "How long a document or photo must have had zero references before its file is deleted.") +
|
||||
" Set to 0 to disable the sweep by default and only clean up categories you explicitly override via Per-category."
|
||||
}
|
||||
minutes={retentionAgeMinutes}
|
||||
disabled={retentionMode === "off"}
|
||||
onChange={setRetentionAgeMinutes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Reclaim space"} />
|
||||
<div className="card-body">
|
||||
<div className="attr-block">
|
||||
<label className="checkline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={evictionEnable}
|
||||
onChange={(event) => setEvictionEnable(event.target.checked)}
|
||||
/>
|
||||
{" "}{"Actively reclaim space once over budget"}
|
||||
</label>
|
||||
<p className="env-field-desc">
|
||||
{"Once total physical storage exceeds the Max total storage budget, actively delete the oldest files (regardless of category or age) until back under budget -- the same way \"hard\" retention mode purges files. Independent of the retention mode: this can run even when that's Off. Off by default, since this changes the storage budget from block-new-uploads-only to also reclaiming from existing files."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Manual purge"} />
|
||||
<div className="card-body">
|
||||
<div className="attr-block">
|
||||
<button className="btn danger icon-text" type="button" onClick={() => setManualPurgeOpen(true)}>
|
||||
<Settings2 size={15} /> {"Manually purge storage..."}
|
||||
</button>
|
||||
<span className="env-field-desc">
|
||||
{"Delete media file bytes right now by hand-picked category and an optional age cutoff, independent of the retention settings above."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{categoryModalOpen && (
|
||||
<CategoryRetentionModal
|
||||
categoryAgeMinutes={categoryAgeMinutes}
|
||||
onChange={(key, minutes) => setCategoryAgeMinutes((prev) => ({ ...prev, [key]: minutes }))}
|
||||
disabled={retentionMode === "off"}
|
||||
onClose={() => setCategoryModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{manualPurgeOpen && <ManualPurgeStorageModal onClose={() => setManualPurgeOpen(false)} />}
|
||||
|
||||
<div className="gift-table-actions env-save-row">
|
||||
<ActionButton
|
||||
tone="warn"
|
||||
label={"Save limits & retention settings"}
|
||||
path="/api/actions/update-server-env"
|
||||
payload={() => ({ values: pendingValues })}
|
||||
disabled={!hasChanges || uploadCeilingExceeded}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
import { EyeOff, ShieldOff } from "lucide-react";
|
||||
import { ShieldOff } from "lucide-react";
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import { StatusScreen } from "./components/StatusScreen";
|
||||
import type { Navigate } from "./routing";
|
||||
import { Alert, PageFrame } from "./components/ui";
|
||||
// Permission names exactly as the backend spells them
|
||||
// (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
|
||||
|
|
@ -15,25 +12,6 @@ export const permissionVerificationRevoke = "verification.revoke";
|
|||
// verifier roster, the icon catalogue and taking a granted mark away.
|
||||
export const permissionBotVerificationReview = "botverification.review";
|
||||
export const permissionBotVerificationManage = "botverification.manage";
|
||||
// Server Settings: identity, .env, restart/update. One right, not
|
||||
// review/manage -- see the constant's doc comment in security.go.
|
||||
export const permissionServerManage = "server.manage";
|
||||
// Operator accounts. The one right that can hand out every other right, so it
|
||||
// is never implied by anything else -- see the constant's doc comment in
|
||||
// security.go.
|
||||
export const permissionAdminsManage = "admins.manage";
|
||||
// Section rights, in read/manage pairs following the sidebar -- see the const
|
||||
// block in security.go, which these must match exactly.
|
||||
export const permissionAccountsRead = "accounts.read";
|
||||
export const permissionChannelsRead = "channels.read";
|
||||
export const permissionBotsRead = "bots.read";
|
||||
export const permissionMessagesRead = "messages.read";
|
||||
export const permissionModerationReview = "moderation.review";
|
||||
export const permissionBroadcastsRead = "broadcasts.read";
|
||||
export const permissionStorageRead = "storage.read";
|
||||
export const permissionContentRead = "content.read";
|
||||
export const permissionUsernamesRead = "usernames.read";
|
||||
export const permissionDashboardRead = "dashboard.read";
|
||||
|
||||
// GET /api/session is read once at boot; the panel keeps the answer here so a
|
||||
// section the session may not use is hidden instead of rendered into a 403. This
|
||||
|
|
@ -86,178 +64,51 @@ export function useThirdPartyVerificationHidden(): boolean {
|
|||
}
|
||||
|
||||
// PermissionGate is what a direct URL hits: without the right the operator gets
|
||||
// a proper refusal naming the missing permission, not an empty table that looks
|
||||
// an explanation naming the missing permission, not an empty table that looks
|
||||
// like "no data".
|
||||
export function PermissionGate({
|
||||
permission,
|
||||
navigate,
|
||||
children
|
||||
}: {
|
||||
permission: string;
|
||||
navigate?: Navigate;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
export function PermissionGate({ permission, children }: { permission: string; children: ReactNode }) {
|
||||
const { can } = usePermissions();
|
||||
if (can(permission)) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return <PermissionDenied permission={permission} navigate={navigate} />;
|
||||
return <PermissionDenied permission={permission} />;
|
||||
}
|
||||
|
||||
export function PermissionDenied({ permission, navigate }: { permission: string; navigate?: Navigate }) {
|
||||
export function PermissionDenied({ permission }: { permission: string }) {
|
||||
return (
|
||||
<StatusScreen
|
||||
code="403"
|
||||
icon={ShieldOff}
|
||||
title={"You do not have access to this section"}
|
||||
detail={permission}
|
||||
navigate={navigate}
|
||||
>
|
||||
{/* Named in the same words the operator editor uses, so "Reveal bot
|
||||
tokens" is what gets asked for rather than "bots.token.read". The raw
|
||||
string is still shown below, because that is what has to be ticked. */}
|
||||
{`It needs the "${permissionTitle(permission)}" permission. Ask an operator who can manage operators to add it, then sign in again.`}
|
||||
</StatusScreen>
|
||||
<PageFrame title={"Not enough rights"} eyebrow={"Console / Access"}>
|
||||
<Alert>{`This session was not granted the ${permission} permission, so the section stays closed.`}</Alert>
|
||||
<section className="section-block">
|
||||
<div className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title"><ShieldOff size={16} /> {"Section unavailable"}</div>
|
||||
<div className="entity-subtitle">{"Ask an operator to add the permission to TELESRV_ADMIN_UI_PERMISSIONS and sign in again."}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ThirdPartyVerificationHiddenGate is what a direct URL to a third-party
|
||||
// verification page hits while the feature is hidden -- distinct from
|
||||
// PermissionGate because no permission grant (not even "*") changes this.
|
||||
export function ThirdPartyVerificationHiddenGate({
|
||||
navigate,
|
||||
children
|
||||
}: {
|
||||
navigate?: Navigate;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
export function ThirdPartyVerificationHiddenGate({ children }: { children: ReactNode }) {
|
||||
const hidden = useThirdPartyVerificationHidden();
|
||||
if (!hidden) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return (
|
||||
<StatusScreen
|
||||
code="404"
|
||||
icon={EyeOff}
|
||||
title={"This section is switched off"}
|
||||
detail="TELESRV_HIDE_THIRD_PARTY_VERIFICATION=false"
|
||||
navigate={navigate}
|
||||
>
|
||||
{"Third-party bot verification is not finished and is hidden on this server. It is a server setting, not a permission -- no account can see it while it is off."}
|
||||
</StatusScreen>
|
||||
<PageFrame title={"Feature hidden"} eyebrow={"Console / Third-party marks"}>
|
||||
<Alert>{"Third-party bot verification is hidden on this server (TELESRV_HIDE_THIRD_PARTY_VERIFICATION=true)."}</Alert>
|
||||
<section className="section-block">
|
||||
<div className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title"><ShieldOff size={16} /> {"Not fully finished"}</div>
|
||||
<div className="entity-subtitle">{"This feature may cause unstable server behavior and is hidden by default. Set TELESRV_HIDE_THIRD_PARTY_VERIFICATION=false to re-enable it."}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// Human-readable names for the permission strings. The raw value is what the
|
||||
// backend stores and checks, but "content.manage" is a machine's word for it --
|
||||
// an operator ticking boxes should read what the right actually lets someone do.
|
||||
//
|
||||
// Anything missing from this map falls back to the raw string rather than being
|
||||
// hidden, so a right added on the server still appears (just untranslated)
|
||||
// instead of silently vanishing from the editor.
|
||||
const permissionLabels: Record<string, { title: string; hint: string }> = {
|
||||
"accounts.read": { title: "View accounts", hint: "Browse users, their profiles and sessions" },
|
||||
"accounts.manage": { title: "Edit accounts", hint: "Change profiles, usernames, freeze and revoke sessions" },
|
||||
"channels.read": { title: "View groups and channels", hint: "Browse supergroups and channels" },
|
||||
"channels.manage": { title: "Edit groups and channels", hint: "Change settings, usernames and avatars" },
|
||||
"bots.read": { title: "View bots", hint: "Browse the bot list and their details" },
|
||||
"bots.manage": { title: "Create and delete bots", hint: "Add new bots and remove existing ones" },
|
||||
"bots.token.read": { title: "Reveal bot tokens", hint: "Export a bot's live credential" },
|
||||
"messages.read": { title: "View messages", hint: "Read private and group message history" },
|
||||
"messages.manage": { title: "Delete messages", hint: "Remove messages and clear history" },
|
||||
"moderation.review": { title: "Handle reports", hint: "Work the moderation queue and decide cases" },
|
||||
"broadcasts.read": { title: "View broadcasts", hint: "See past and scheduled broadcasts" },
|
||||
"broadcasts.send": { title: "Send broadcasts", hint: "Deliver a message to many users at once" },
|
||||
"content.read": { title: "View stickers, emoji and GIFs", hint: "Browse the packs and the GIF catalogue" },
|
||||
"content.manage": { title: "Edit stickers, emoji and GIFs", hint: "Create, rename and remove packs and catalogue entries" },
|
||||
"usernames.read": { title: "View NFT usernames", hint: "Browse collectible usernames" },
|
||||
"usernames.manage": { title: "Manage NFT usernames", hint: "Mint, transfer and revoke collectible usernames" },
|
||||
"storage.read": { title: "View storage", hint: "See media usage per account" },
|
||||
"storage.manage": { title: "Purge storage", hint: "Manually delete stored media" },
|
||||
"dashboard.read": { title: "View the dashboard", hint: "See the overview counters and server health" },
|
||||
"premium.manage": { title: "Manage Premium", hint: "Grant, revoke and refund Premium" },
|
||||
"verification.review": { title: "Verify accounts", hint: "Work the verification queue and grant badges" },
|
||||
"verification.revoke": { title: "Remove verification", hint: "Take a granted badge away (needs the right above too)" },
|
||||
"botverification.review": { title: "Handle third-party marks", hint: "Work the third-party verification queue" },
|
||||
"botverification.manage": { title: "Appoint verifiers", hint: "Grant verifier status and curate mark icons" },
|
||||
"server.manage": { title: "Server settings", hint: "Identity, .env editing, restart and update" },
|
||||
"admins.manage": { title: "Manage operators", hint: "Create operators and decide what everyone can do" },
|
||||
"*": { title: "Full access", hint: "Every right, including future ones" }
|
||||
};
|
||||
|
||||
export function permissionTitle(permission: string): string {
|
||||
return permissionLabels[permission]?.title ?? permission;
|
||||
}
|
||||
|
||||
export function permissionHint(permission: string): string {
|
||||
return permissionLabels[permission]?.hint ?? "";
|
||||
}
|
||||
|
||||
// Rights grouped by the part of the console they govern, so the editor reads as
|
||||
// a few short decisions instead of one wall of twenty-six checkboxes.
|
||||
//
|
||||
// The order is roughly "everyday work first, keys to the building last": an
|
||||
// operator scanning down the list meets the routine rights before the ones that
|
||||
// can undo the deployment.
|
||||
export const permissionGroups: { title: string; hint: string; permissions: string[] }[] = [
|
||||
{
|
||||
title: "People and chats",
|
||||
hint: "Users, groups and their message history",
|
||||
permissions: ["accounts.read", "accounts.manage", "channels.read", "channels.manage", "messages.read", "messages.manage"]
|
||||
},
|
||||
{
|
||||
title: "Moderation and verification",
|
||||
hint: "Reports, badges and third-party marks",
|
||||
permissions: ["moderation.review", "verification.review", "verification.revoke", "botverification.review", "botverification.manage"]
|
||||
},
|
||||
{
|
||||
title: "Content",
|
||||
hint: "Sticker packs, emoji, GIFs and collectible usernames",
|
||||
permissions: ["content.read", "content.manage", "usernames.read", "usernames.manage"]
|
||||
},
|
||||
{
|
||||
title: "Bots",
|
||||
hint: "The bot roster and its credentials",
|
||||
permissions: ["bots.read", "bots.manage", "bots.token.read"]
|
||||
},
|
||||
{
|
||||
title: "Broadcasting",
|
||||
hint: "Messages sent to many users at once",
|
||||
permissions: ["broadcasts.read", "broadcasts.send"]
|
||||
},
|
||||
{
|
||||
title: "Storage and overview",
|
||||
hint: "Media usage and the dashboard",
|
||||
permissions: ["storage.read", "storage.manage", "dashboard.read"]
|
||||
},
|
||||
{
|
||||
title: "Billing",
|
||||
hint: "Premium grants and refunds",
|
||||
permissions: ["premium.manage"]
|
||||
},
|
||||
{
|
||||
title: "The console itself",
|
||||
hint: "The two rights that can change the deployment or hand out every other right",
|
||||
permissions: ["server.manage", "admins.manage"]
|
||||
}
|
||||
];
|
||||
|
||||
// groupPermissions arranges the server's list into the groups above. Anything
|
||||
// the server offers that no group claims is collected at the end rather than
|
||||
// dropped, so a right added on the backend still appears here without this file
|
||||
// having to be edited first.
|
||||
export function groupPermissions(available: string[]): { title: string; hint: string; permissions: string[] }[] {
|
||||
const remaining = new Set(available);
|
||||
const out: { title: string; hint: string; permissions: string[] }[] = [];
|
||||
for (const group of permissionGroups) {
|
||||
const present = group.permissions.filter((p) => remaining.has(p));
|
||||
present.forEach((p) => remaining.delete(p));
|
||||
if (present.length > 0) {
|
||||
out.push({ title: group.title, hint: group.hint, permissions: present });
|
||||
}
|
||||
}
|
||||
if (remaining.size > 0) {
|
||||
out.push({ title: "Other", hint: "Rights this console version does not have a group for", permissions: [...remaining] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,5 @@ export function routeTitle(pathname: string): string {
|
|||
if (pathname.startsWith("/messages")) return "Message Audit";
|
||||
if (pathname.startsWith("/stickers")) return "Stickers";
|
||||
if (pathname.startsWith("/gif-catalog")) return "GIFs";
|
||||
if (pathname.startsWith("/server-settings")) return "Server Settings";
|
||||
return "Operations Console";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,6 @@
|
|||
--panel: #ffffff;
|
||||
--panel-subtle: #f7f9fc;
|
||||
--panel-strong: #f1f5f9;
|
||||
/* Loading placeholders. Kept as their own pair rather than reusing the
|
||||
panel shades because the sheen has to read as lighter than the base in
|
||||
both themes, and the panel ramp runs the opposite way in dark. */
|
||||
--skeleton-base: #e6ebf2;
|
||||
--skeleton-sheen: #f4f7fa;
|
||||
--surface-soft: #f2f7fd;
|
||||
--overlay: rgba(24, 34, 47, 0.42);
|
||||
--topbar-bg: rgba(255, 255, 255, 0.94);
|
||||
|
|
@ -105,8 +100,6 @@
|
|||
--panel: #171f28;
|
||||
--panel-subtle: #1c2530;
|
||||
--panel-strong: #212c38;
|
||||
--skeleton-base: #212c38;
|
||||
--skeleton-sheen: #2e3d4c;
|
||||
--surface-soft: #1a232d;
|
||||
--overlay: rgba(5, 8, 12, 0.62);
|
||||
--topbar-bg: rgba(21, 28, 36, 0.86);
|
||||
|
|
@ -238,8 +231,6 @@ a {
|
|||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
|
|
@ -247,7 +238,7 @@ a {
|
|||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
|
|
@ -263,35 +254,6 @@ a {
|
|||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sidebar-server-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-server-action {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--sidebar-text);
|
||||
background: transparent;
|
||||
border-color: var(--sidebar-line);
|
||||
}
|
||||
|
||||
.sidebar-server-action:hover:not(:disabled) {
|
||||
background: var(--sidebar-line);
|
||||
}
|
||||
|
||||
.sidebar-server-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sidebar-server-action-error {
|
||||
padding: 0 4px;
|
||||
color: var(--danger, #e5484d);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
padding: 0 8px;
|
||||
color: var(--sidebar-faint);
|
||||
|
|
@ -301,14 +263,6 @@ a {
|
|||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.sidebar-api-layer,
|
||||
.sidebar-build {
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
|
@ -482,11 +436,8 @@ a {
|
|||
.actor-pill {
|
||||
display: inline-flex;
|
||||
min-height: 30px;
|
||||
/* Icon and name read as one label rather than two adjacent things. */
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
font-weight: 600;
|
||||
padding: 0 10px;
|
||||
color: var(--text-soft);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
|
|
@ -506,61 +457,3 @@ a {
|
|||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Collapsible navigation.
|
||||
Scoped to the width where the sidebar is actually a sidebar: below 1120px the
|
||||
shell already stacks it into a horizontal strip (05-responsive.css), and
|
||||
collapsing that would just hide the labels of a bar that has room for them. */
|
||||
@media (min-width: 1121px) {
|
||||
.shell--nav-collapsed {
|
||||
grid-template-columns: 68px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* Everything that only makes sense with room for words goes away: the
|
||||
wordmark beside the logo, the "Navigation" heading, the version block and
|
||||
the Connect/Share buttons. The icons and their tooltips remain. */
|
||||
.shell--nav-collapsed .brand > span:not(.brand-mark),
|
||||
.shell--nav-collapsed .sidebar-label,
|
||||
.shell--nav-collapsed .sidebar-status,
|
||||
.shell--nav-collapsed .sidebar-server-actions,
|
||||
.shell--nav-collapsed .nav-item-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shell--nav-collapsed .sidebar {
|
||||
padding: 18px 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shell--nav-collapsed .brand {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* One column instead of icon + label, so the glyph sits centred in the rail
|
||||
rather than clinging to the left edge where the text used to start. */
|
||||
.shell--nav-collapsed .nav-item {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: center;
|
||||
padding: 0;
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
.shell--nav-collapsed .nav-list {
|
||||
justify-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* The toggle sits with the page title rather than inside the sidebar: it has to
|
||||
stay reachable at 68px wide, and a control that hides with the thing it
|
||||
controls is a trap. */
|
||||
.topbar-lead {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,52 +111,6 @@ button.stat-tile.clickable {
|
|||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* Loading placeholder. Sized in em so it occupies the same box as the text it
|
||||
stands in for -- the tile must not resize when the real value arrives. */
|
||||
.skeleton {
|
||||
display: inline-block;
|
||||
border-radius: 6px;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--skeleton-base) 25%,
|
||||
var(--skeleton-sheen) 37%,
|
||||
var(--skeleton-base) 63%
|
||||
);
|
||||
background-size: 400% 100%;
|
||||
animation: skeletonShimmer 1.4s ease-in-out infinite;
|
||||
/* Nothing here is real content: keep it out of the accessibility tree and
|
||||
out of copied text. The live region announcing "loading" belongs on the
|
||||
container, not on every bar. */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.skeleton-value {
|
||||
height: 0.85em;
|
||||
width: 2.75ch;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
height: 0.8em;
|
||||
width: 6ch;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes skeletonShimmer {
|
||||
from {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
to {
|
||||
background-position: 0 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-tile.warn .stat-tile-value {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
|
@ -825,27 +779,6 @@ textarea:focus {
|
|||
font-weight: 800;
|
||||
}
|
||||
|
||||
.sort-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sort-header:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sort-header-idle {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--panel-subtle);
|
||||
}
|
||||
|
|
@ -936,251 +869,3 @@ textarea:focus {
|
|||
}
|
||||
}
|
||||
|
||||
/* Operator accounts (AdminUsersPage). The permission picker is a checkbox grid
|
||||
rather than a role dropdown: the backend stores a permission set, so the
|
||||
screen shows exactly that set instead of a friendlier abstraction that could
|
||||
drift from what the routes enforce.
|
||||
|
||||
Selectors carry .form-stack because these labels live inside one, and
|
||||
".form-stack label { display: grid }" would otherwise out-specify a bare
|
||||
.permission-item and stack the box above its own text. */
|
||||
.permission-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 6px;
|
||||
margin: 2px 0 8px;
|
||||
}
|
||||
|
||||
.form-stack .permission-item,
|
||||
.permission-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: auto;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-subtle);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: border-color 140ms ease;
|
||||
}
|
||||
|
||||
.permission-item:hover {
|
||||
border-color: var(--brand-tint-border);
|
||||
}
|
||||
|
||||
/* Explicit box size: the generic "input" rule gives fields a text-input's
|
||||
padding and .form-stack stretches them to 100%, neither of which suits a
|
||||
checkbox. */
|
||||
.form-stack .permission-item input[type="checkbox"],
|
||||
.permission-item input[type="checkbox"] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
accent-color: var(--brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Title over hint. The checkbox stays vertically centred against the pair
|
||||
rather than against the first line, so a two-line entry does not look
|
||||
top-heavy. */
|
||||
.permission-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.permission-copy strong {
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.permission-copy small {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The standalone Enabled toggle is one control, not a grid cell, so it sits at
|
||||
its natural width instead of stretching across the row. */
|
||||
.permission-item.standalone {
|
||||
justify-self: start;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
/* A granted permission, listed in the table. It carries the human name now, so
|
||||
it is set in the UI font -- the raw "content.manage" string stays available
|
||||
as the chip's tooltip for anyone who needs to match it against the .env. */
|
||||
.chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-block;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-strong);
|
||||
color: var(--text-soft);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-strong);
|
||||
color: var(--text-soft);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pill.good {
|
||||
border-color: var(--good-border);
|
||||
color: var(--good);
|
||||
}
|
||||
|
||||
/* Grouped permission editor. Each group is a labelled block so the twenty-odd
|
||||
rights read as a few short decisions rather than one undifferentiated run. */
|
||||
.permission-groups {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.permission-group {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.permission-group-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.permission-group-head strong {
|
||||
display: block;
|
||||
color: var(--heading);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.permission-group-head small {
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Username and password sit side by side above the rights, so the identity
|
||||
fields do not read as the first permission group. */
|
||||
.operator-identity {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* The password dialog has one field; the command modal's default width would
|
||||
leave it stranded in the middle of a mostly empty sheet. */
|
||||
.modal.narrow {
|
||||
width: min(460px, 100%);
|
||||
}
|
||||
|
||||
/* Refusal screens (403 / 404). A full-height panel rather than an alert strip
|
||||
above an empty page: hitting one is the end of that navigation, not a
|
||||
warning about the page you are on. */
|
||||
.status-screen {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: min(560px, 70vh);
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
padding: 32px 24px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* The status code as a watermark. Large enough to be read instantly, faint
|
||||
enough that the sentence below it is what the eye lands on. */
|
||||
.status-screen-code {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
color: var(--heading);
|
||||
font-size: clamp(140px, 26vw, 280px);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
opacity: 0.05;
|
||||
transform: translate(-50%, -50%);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.status-screen-body {
|
||||
position: relative;
|
||||
display: grid;
|
||||
max-width: 460px;
|
||||
gap: 12px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.status-screen-icon {
|
||||
display: grid;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
place-items: center;
|
||||
color: var(--brand);
|
||||
background: var(--brand-tint);
|
||||
border: 1px solid var(--brand-tint-border);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-screen-body h1 {
|
||||
margin: 0;
|
||||
color: var(--heading);
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.status-screen-body p {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.status-screen-detail {
|
||||
padding: 5px 10px;
|
||||
color: var(--text-soft);
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11.5px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.status-screen-body .btn {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,40 +206,6 @@
|
|||
border-top: 0;
|
||||
}
|
||||
|
||||
/* A GitHub-Settings-style "Danger Zone" box: a red-bordered card with its own
|
||||
title strip, wrapping the specific fields/toggles that reach outside plain
|
||||
per-user data (e.g. StoragePage's Avatar/GIF retention controls, which also
|
||||
govern the built-in system bot avatars and the bundled GIF catalog) --
|
||||
distinct from .danger-zone above, which is just a plain action-row divider,
|
||||
not a bordered card. */
|
||||
.danger-zone-box {
|
||||
margin: 4px 0 14px;
|
||||
border: 1px solid var(--danger-border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.danger-zone-box-title {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--danger);
|
||||
background: var(--danger-tint);
|
||||
border-bottom: 1px solid var(--danger-border);
|
||||
}
|
||||
|
||||
.danger-zone-box-body {
|
||||
padding: 10px 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.danger-zone-box-body .env-field-desc {
|
||||
color: var(--danger-text, var(--danger));
|
||||
}
|
||||
|
||||
.authorization-block {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
|
@ -962,467 +928,3 @@
|
|||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* --- Server Settings ---------------------------------------------------- */
|
||||
|
||||
/* Bare .card-body (outside .action-groups, which scopes its own flex rules)
|
||||
just needs a sensible vertical rhythm below a SectionHead -- used as-is by
|
||||
IdentitySection/ServerControlSection on the Server Settings page. */
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.server-identity-fields {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Server identity card: icon+upload fixed on the left, name+description
|
||||
filling the rest of the section's width on the right, full-width Save
|
||||
button spanning the whole section beneath. */
|
||||
.identity-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.identity-layout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.identity-layout .server-identity-fields {
|
||||
flex: 1 1 auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.identity-layout .form-field textarea {
|
||||
min-height: 92px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.identity-save-row .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.server-icon-fallback {
|
||||
color: var(--muted);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px dashed var(--line-strong);
|
||||
}
|
||||
|
||||
.env-groups {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.env-group {
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.env-group-toggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
background: var(--panel-subtle);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
|
||||
.env-group-toggle:hover {
|
||||
background: var(--brand-tint);
|
||||
}
|
||||
|
||||
.env-group-toggle-text {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.env-group-toggle-title {
|
||||
color: var(--heading);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.env-group-toggle-count {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.env-group-chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
.env-group.open .env-group-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.env-group-body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.env-group-desc {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.env-field .env-field-desc {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.env-save-row {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.restart-overlay {
|
||||
width: min(340px, 100%);
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
background-image: radial-gradient(220px 140px at 50% 0%, var(--brand-tint) 0%, rgba(0, 0, 0, 0) 75%);
|
||||
}
|
||||
|
||||
/* .restart-overlay-body qualified by its .restart-overlay parent so this
|
||||
padding/gap wins over the bare .command-body rule in 04-modal-and-login
|
||||
(equal specificity otherwise, and that file loads after this one). */
|
||||
.restart-overlay .restart-overlay-body {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 6px;
|
||||
padding: 36px 24px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.restart-overlay-badge {
|
||||
display: grid;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: 10px;
|
||||
place-items: center;
|
||||
color: var(--brand);
|
||||
background: var(--brand-tint);
|
||||
border: 1px solid var(--brand-tint-border);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 8px var(--brand-tint);
|
||||
}
|
||||
|
||||
.restart-overlay-badge.warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-tint);
|
||||
border-color: var(--warn-border);
|
||||
box-shadow: 0 0 0 8px var(--warn-tint);
|
||||
}
|
||||
|
||||
.restart-overlay-spin {
|
||||
animation: spin 1.4s linear infinite;
|
||||
}
|
||||
|
||||
.restart-overlay-heading {
|
||||
margin: 0;
|
||||
color: var(--heading);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.restart-overlay-body p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.restart-overlay-progress {
|
||||
width: min(220px, 100%);
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.restart-overlay-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Server Settings' Settings/Services tab bar. */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
margin-bottom: 18px;
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Live Docker/process status cards -- Services tab. */
|
||||
.service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.service-card-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.service-card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.service-card-name {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.service-card-detail {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.service-card-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: capitalize;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.service-card.tone-good .service-card-icon { color: var(--good); }
|
||||
.service-card.tone-good .service-card-status {
|
||||
color: var(--good);
|
||||
background: var(--good-tint);
|
||||
border: 1px solid var(--good-border);
|
||||
}
|
||||
|
||||
.service-card.tone-warn .service-card-icon { color: var(--warn); }
|
||||
.service-card.tone-warn .service-card-status {
|
||||
color: var(--warn);
|
||||
background: var(--warn-tint);
|
||||
border: 1px solid var(--warn-border);
|
||||
}
|
||||
|
||||
.service-card.tone-danger .service-card-icon { color: var(--danger); }
|
||||
.service-card.tone-danger .service-card-status {
|
||||
color: var(--danger);
|
||||
background: var(--danger-tint);
|
||||
border: 1px solid var(--danger-border);
|
||||
}
|
||||
|
||||
.service-card.tone-idle .service-card-status {
|
||||
color: var(--muted);
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.services-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* The message itself, rendered the way it was read rather than the way it is
|
||||
stored. Leads every message detail page; the database rows sit below it in a
|
||||
folded block. */
|
||||
.message-view {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message-view-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-view-head strong {
|
||||
display: block;
|
||||
color: var(--heading);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message-view-head small {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Given a bubble's shape on purpose: it is the one element on the page that is
|
||||
the message rather than a fact about it. */
|
||||
.message-bubble {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 720px;
|
||||
padding: 14px 16px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
border-top-left-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.message-text {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
/* Message text keeps its own line breaks; collapsing them would silently
|
||||
reformat what was actually sent. */
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.message-text.empty {
|
||||
color: var(--muted-2);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.message-attachment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.message-attachment-icon {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
color: var(--brand);
|
||||
background: var(--brand-tint);
|
||||
border: 1px solid var(--brand-tint-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.message-attachment-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.message-attachment-copy strong {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-attachment-copy small {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-flags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Folded raw rows. Closed by default so the page opens on the message, not on
|
||||
three JSON dumps -- still one click away for the times the stored state is
|
||||
the actual question. */
|
||||
.raw-details > summary {
|
||||
padding: 8px 10px;
|
||||
color: var(--text-soft);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.raw-details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.raw-details > summary::before {
|
||||
content: "▸ ";
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.raw-details[open] > summary::before {
|
||||
content: "▾ ";
|
||||
}
|
||||
|
||||
.raw-details[open] > summary {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,33 +63,6 @@
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.add-server-link-modal {
|
||||
width: min(920px, 100%);
|
||||
max-height: min(880px, calc(100vh - 48px));
|
||||
}
|
||||
|
||||
.add-server-link-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.add-server-link-field textarea {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.add-server-link-hint {
|
||||
margin: 0;
|
||||
color: var(--muted-2);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.command-body {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
|
|
@ -272,25 +245,21 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-orbs {
|
||||
.login-page .bg-orbs {
|
||||
position: absolute;
|
||||
inset: -60px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
/* Eased so the pointer parallax glides instead of snapping to the cursor,
|
||||
matching the marketing site. will-change keeps it off the main thread. */
|
||||
transition: transform 0.3s ease-out;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.bg-orb {
|
||||
.login-page .bg-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bg-orb--1 {
|
||||
.login-page .bg-orb--1 {
|
||||
top: -15%;
|
||||
left: -10%;
|
||||
width: 700px;
|
||||
|
|
@ -299,7 +268,7 @@
|
|||
animation: loginOrbFloat1 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bg-orb--2 {
|
||||
.login-page .bg-orb--2 {
|
||||
top: 25%;
|
||||
right: -15%;
|
||||
width: 600px;
|
||||
|
|
@ -308,7 +277,7 @@
|
|||
animation: loginOrbFloat2 24s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bg-orb--3 {
|
||||
.login-page .bg-orb--3 {
|
||||
bottom: -15%;
|
||||
left: 30%;
|
||||
width: 500px;
|
||||
|
|
@ -334,15 +303,14 @@
|
|||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.bg-orb { filter: blur(60px); }
|
||||
.bg-orb--1 { width: 350px; height: 350px; }
|
||||
.bg-orb--2 { width: 300px; height: 300px; }
|
||||
.bg-orb--3 { width: 250px; height: 250px; }
|
||||
.login-page .bg-orb { filter: blur(60px); }
|
||||
.login-page .bg-orb--1 { width: 350px; height: 350px; }
|
||||
.login-page .bg-orb--2 { width: 300px; height: 300px; }
|
||||
.login-page .bg-orb--3 { width: 250px; height: 250px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bg-orb { animation: none; }
|
||||
.bg-orbs { transition: none; }
|
||||
.login-page .bg-orb { animation: none; }
|
||||
}
|
||||
|
||||
.login-page .login-panel {
|
||||
|
|
@ -352,19 +320,13 @@
|
|||
|
||||
.login-panel {
|
||||
display: grid;
|
||||
width: min(480px, 100%);
|
||||
gap: 22px;
|
||||
padding: 30px;
|
||||
width: min(420px, 100%);
|
||||
gap: 18px;
|
||||
padding: 22px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
/* The rest of the panel (chrome excluded) is scoped up from the admin
|
||||
panel's normal 13px/dense sizing -- everywhere else on this site is a
|
||||
data-table console meant to be scanned, but this screen is one form,
|
||||
seen once per session, at whatever size someone's monitor happens to
|
||||
be. */
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.login-head {
|
||||
|
|
@ -382,25 +344,6 @@
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.login-head-actions .icon-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.login-panel .brand-mark {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.login-panel .brand strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.login-panel .brand small {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-chip {
|
||||
display: inline-flex;
|
||||
min-height: 24px;
|
||||
|
|
@ -413,6 +356,17 @@
|
|||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-copy h1 {
|
||||
margin: 0;
|
||||
color: var(--heading);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.login-copy p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.form-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
|
|
@ -427,132 +381,6 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.login-panel .form-stack input {
|
||||
height: 46px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.login-panel .btn {
|
||||
min-height: 44px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* Login form: username and password are two panels of equal width, slid
|
||||
horizontally by translateX on the track rather than shown/hidden, so
|
||||
moving between them reads as one continuous field instead of a page
|
||||
swap. The clipping .login-wizard is what makes the off-screen step
|
||||
invisible; aria-hidden + tabIndex={-1} in the component keep it out of
|
||||
the tab order and screen readers while it's off-screen. */
|
||||
.login-wizard {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-wizard-track {
|
||||
display: flex;
|
||||
transition: transform 220ms ease;
|
||||
}
|
||||
|
||||
.login-wizard-step {
|
||||
flex: 0 0 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Back sits beside Log in rather than squeezed against the "Password"
|
||||
label -- as a normal-height text+icon button it has a real click target
|
||||
and room to breathe, instead of a 22px icon crowding the label above a
|
||||
400px-wide field. */
|
||||
.login-wizard-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-wizard-actions .btn.primary {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.login-wizard-track {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* First-run setup wizard (components/SetupWizard.tsx) -- built on the same
|
||||
.login-page/.login-panel shell as the sign-in screen, just wider: this one
|
||||
holds a name+description+icon row and env fields, not two single inputs. */
|
||||
.setup-wizard-panel {
|
||||
width: min(640px, 100%);
|
||||
max-height: min(760px, calc(100vh - 48px));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wizard-steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wizard-steps .command-step {
|
||||
flex: 1 1 0;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
padding: 0 6px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.wizard-steps .command-step strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wizard-step-body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.wizard-step-body > p {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.wizard-step-body > p.wizard-step-hint {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wizard-step-body > p.wizard-welcome-greeting {
|
||||
color: var(--heading);
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.wizard-identity-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.wizard-identity-row .server-identity-fields {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.wizard-steps .command-step strong {
|
||||
display: none;
|
||||
}
|
||||
.wizard-identity-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.boot-screen {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
|
|
@ -597,96 +425,3 @@
|
|||
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;
|
||||
}
|
||||
|
||||
/* The drifting icon field behind the sign-in card. Sits above the blurred orbs
|
||||
and below the panel, so the two background layers read as one scene.
|
||||
Fills whichever .app-background contains it. */
|
||||
.bg-icons {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
color: var(--brand-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bg-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
|
||||
/* The background wrapper. On the sign-in screen it fills that screen; behind
|
||||
the workspace it is pinned to the viewport so it does not scroll away under
|
||||
a long page, and so the icons -- which bounce off window bounds -- stay
|
||||
inside the area they are drawn in. */
|
||||
.app-background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-background--workspace {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
/* Quieter behind real content than behind a lone sign-in card: the panel is
|
||||
dense with tables and numbers, and the background has to stay background. */
|
||||
.app-background--workspace .bg-icons {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.app-background--workspace .bg-orb {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* The chrome paints over the background: the sidebar hides the part of it that
|
||||
would otherwise show through the navigation, which is also why the wrapper
|
||||
can span the whole viewport instead of hard-coding the sidebar's width.
|
||||
Only z-index is set here -- the sidebar is already sticky, and restating
|
||||
position would be a chance to change it by accident. The topbar needs
|
||||
nothing: it carries z-index 20 of its own, and a rule here would out-specify
|
||||
its own "position: sticky" and quietly stop it sticking. */
|
||||
.shell > .sidebar {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.workspace > .content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -591,28 +591,6 @@ export type AdminSession = {
|
|||
// 404, so this is a UI convenience on top of a real enforcement, not the
|
||||
// enforcement itself.
|
||||
hide_third_party_verification?: boolean;
|
||||
// False until the first-run setup wizard has been finished -- see
|
||||
// identity.Store.SetupPending. Missing/undefined is treated as true (an
|
||||
// admin binary older than this field never gates on it), so only an
|
||||
// explicit false shows the wizard.
|
||||
setup_completed?: boolean;
|
||||
// Random per admin-process-start value -- see the Go handler's doc
|
||||
// comment. Used by Server Settings' Restart/Update flow to detect a
|
||||
// genuinely new admin process after asking it to bounce.
|
||||
boot_id?: string;
|
||||
// Every MTProto TL schema layer this server binary can admit and encode
|
||||
// for (oldest first) -- the server is multi-layer, so this is the whole
|
||||
// supported set, not just the newest one. Shown in the sidebar footer
|
||||
// above the build/commit line.
|
||||
api_layers?: number[];
|
||||
// This admin binary's own build -- shown under "Version" in the sidebar
|
||||
// footer so an operator can tell which build is actually running.
|
||||
build?: {
|
||||
commit: string;
|
||||
short_commit: string;
|
||||
dirty: boolean;
|
||||
build_time: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminLoginResult = AdminSession & {
|
||||
|
|
@ -743,7 +721,7 @@ export type SharedDeviceGroupListResponse = {
|
|||
export type StorageStatsResponse = {
|
||||
PhysicalBytes: string;
|
||||
LogicalBytes: string;
|
||||
SystemBytes: string;
|
||||
UnattributedBytes: string;
|
||||
DocumentCount: string;
|
||||
PhotoCount: string;
|
||||
AccountCount: string;
|
||||
|
|
@ -769,11 +747,6 @@ export type HostStatsSnapshot = {
|
|||
MemTotalBytes: number;
|
||||
DiskFreeBytes: number;
|
||||
DiskTotalBytes: number;
|
||||
// False when the disk-space sample itself failed (wrong path, not
|
||||
// created yet, etc) -- DiskFreeBytes/DiskTotalBytes are stale/zero in
|
||||
// that case, not "the disk is actually full". Independent of Ready,
|
||||
// which only covers CPU/memory.
|
||||
DiskReady: boolean;
|
||||
Ready: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -820,11 +793,9 @@ export type BroadcastRow = {
|
|||
ID: number;
|
||||
Message: string;
|
||||
TargetMode: string;
|
||||
TargetCount: number;
|
||||
MaterializedCount: number;
|
||||
TotalCount: number;
|
||||
SentCount: number;
|
||||
FailedCount: number;
|
||||
EnumerationDone: boolean;
|
||||
CreatedBy: string;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
|
@ -869,94 +840,3 @@ export type GroupMessageListResponse = {
|
|||
limit: number;
|
||||
rows: GroupMessageRow[];
|
||||
};
|
||||
|
||||
export type ServerIdentity = {
|
||||
name: string;
|
||||
description: string;
|
||||
icon_ext?: string;
|
||||
// welcome_message_*_template: raw admin-panel override for the 777000
|
||||
// login-notification message, empty when unset (falls back to the
|
||||
// TELESRV_WELCOME_MESSAGE_*_TEMPLATE env var, then a built-in default).
|
||||
welcome_message_phone_template?: string;
|
||||
welcome_message_email_template?: string;
|
||||
// default_welcome_message_*_template: the effective fallback text this
|
||||
// admin process currently reads (env var if set, else the compiled-in
|
||||
// copy) -- shown when the override above is empty.
|
||||
default_welcome_message_phone_template: string;
|
||||
default_welcome_message_email_template: string;
|
||||
// login_code_message_template: raw admin-panel override for the 777000
|
||||
// login-code delivery message, empty when unset (falls back to the
|
||||
// TELESRV_LOGIN_CODE_MESSAGE_TEMPLATE env var, then a built-in default).
|
||||
// Unlike the welcome_message_* templates there is only one -- the
|
||||
// message never varies by delivery channel. Must contain the {{code}}
|
||||
// placeholder exactly once (enforced server-side on save).
|
||||
login_code_message_template?: string;
|
||||
// default_login_code_message_template: the effective fallback text this
|
||||
// admin process currently reads -- shown when the override above is empty.
|
||||
default_login_code_message_template: string;
|
||||
};
|
||||
|
||||
export type EnvField = {
|
||||
key: string;
|
||||
default_value: string;
|
||||
description: string;
|
||||
enabled_by_default: boolean;
|
||||
sensitive: boolean;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type EnvGroup = {
|
||||
title: string;
|
||||
description: string;
|
||||
fields: EnvField[];
|
||||
};
|
||||
|
||||
export type ServerStatus = {
|
||||
ServerPID: number;
|
||||
ServerAlive: boolean;
|
||||
AdminPID: number;
|
||||
AdminAlive: boolean;
|
||||
};
|
||||
|
||||
export type DockerService = {
|
||||
name: string;
|
||||
state: string;
|
||||
health: string;
|
||||
};
|
||||
|
||||
// One admin console operator. Mirrors AdminConsoleUser in adminusers.go; the
|
||||
// password hash deliberately has no representation here.
|
||||
export type AdminConsoleUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
permissions: string[];
|
||||
enabled: boolean;
|
||||
token_epoch: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_login_at?: string | null;
|
||||
};
|
||||
|
||||
// The built-in operator backed by TELESRV_ADMIN_UI_PASSWORD / _TOKEN. It has no
|
||||
// database row, so it carries no id and cannot be edited from the panel.
|
||||
export type AdminConsoleSystemOperator = {
|
||||
username: string;
|
||||
permissions: string[];
|
||||
enabled: boolean;
|
||||
system: true;
|
||||
};
|
||||
|
||||
export type AdminConsoleUserList = {
|
||||
system?: AdminConsoleSystemOperator;
|
||||
rows: AdminConsoleUser[];
|
||||
// The rights the server is willing to assign, so the editor cannot drift
|
||||
// from what the routes actually enforce.
|
||||
available_permissions: string[];
|
||||
};
|
||||
|
||||
|
||||
// The two branding fields the login screen may read without a session.
|
||||
export type PublicBranding = {
|
||||
name: string;
|
||||
has_icon: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,16 +38,6 @@ 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":
|
||||
|
|
@ -60,231 +50,6 @@ 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")
|
||||
|
|
@ -313,7 +78,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", loadharness.DefaultPhonePrefix, "possible reserved NANP prefix followed by a six-digit account index")
|
||||
phonePrefix := flags.String("phone-prefix", "+155500", "E.164 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")
|
||||
|
|
@ -364,17 +129,12 @@ 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)")
|
||||
|
|
@ -395,10 +155,8 @@ 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, MessageRate: *messageRate,
|
||||
MessageQueueDepth: *messageQueue, DeliverySettle: *deliverySettle, SampleInterval: *sampleInterval,
|
||||
RPCInterval: *rpcInterval, MessageInterval: *messageInterval, SampleInterval: *sampleInterval,
|
||||
FileInterval: *fileInterval, FileSizeBytes: *fileSize, FileChunkBytes: *fileChunk, SetupTimeout: *setupTimeout,
|
||||
OperationTimeout: *operationTimeout,
|
||||
OfflineFraction: *offlineFraction, OfflineAt: *offlineAt, OfflineFor: *offlineFor,
|
||||
|
|
@ -425,25 +183,6 @@ 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()
|
||||
|
|
@ -458,9 +197,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 scheduled=%d delivered=%d missing=%d\n",
|
||||
fmt.Fprintf(os.Stdout, "pass=%v sessions=%d peak_ready=%d reconnects=%d disconnects=%d flood_waits=%d fatal_errors=%d\n",
|
||||
report.Pass, report.ExpectedSessions, report.PeakReadySessions, report.Reconnects, report.Disconnects,
|
||||
totalFloodWaits(report), report.WorkerFatalErrors, report.MessageScheduled, report.Delivery.Delivered, report.Delivery.Missing)
|
||||
totalFloodWaits(report), report.WorkerFatalErrors)
|
||||
for _, failure := range report.Failures {
|
||||
fmt.Fprintln(os.Stdout, "failure:", failure)
|
||||
}
|
||||
|
|
@ -475,17 +214,12 @@ func totalFloodWaits(report *loadharness.RunReport) uint64 {
|
|||
}
|
||||
|
||||
func usageError() error {
|
||||
return errors.New("expected one of: keygen, provision, plan-dataset, seed, snapshot, mutate-offline, startup-run, run, summarize, help")
|
||||
return errors.New("expected one of: keygen, provision, 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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
// 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,7 +10,6 @@ import (
|
|||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
runtimemetrics "runtime/metrics"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
|
@ -61,17 +60,14 @@ 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"
|
||||
"telesrv/internal/identity"
|
||||
"telesrv/internal/mtprotoedge"
|
||||
obsmetrics "telesrv/internal/observability/metrics"
|
||||
"telesrv/internal/otpdelivery"
|
||||
otpsmtp "telesrv/internal/otpdelivery/smtp"
|
||||
otpwebhook "telesrv/internal/otpdelivery/webhook"
|
||||
"telesrv/internal/procctl"
|
||||
"telesrv/internal/rpc"
|
||||
"telesrv/internal/seed/catalog"
|
||||
"telesrv/internal/sfu"
|
||||
|
|
@ -81,7 +77,6 @@ import (
|
|||
"telesrv/internal/store/redisstore"
|
||||
"telesrv/internal/telegramloginhttp"
|
||||
"telesrv/internal/turnsrv"
|
||||
"telesrv/internal/updatecdn"
|
||||
"telesrv/internal/web"
|
||||
)
|
||||
|
||||
|
|
@ -276,9 +271,8 @@ func startDebugServer(ctx context.Context, addr string, metricsHandler http.Hand
|
|||
func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
|
||||
var mem runtime.MemStats
|
||||
runtime.ReadMemStats(&mem)
|
||||
samples := []obsmetrics.GaugeSample{
|
||||
return []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)},
|
||||
|
|
@ -287,28 +281,6 @@ 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 {
|
||||
|
|
@ -329,15 +301,6 @@ 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)},
|
||||
|
|
@ -480,20 +443,15 @@ type rpcProjectionVerificationNotifier struct {
|
|||
invalidator interface {
|
||||
InvalidateRPCProjectionReadModelForUser(userID int64)
|
||||
InvalidateRPCProjectionReadModelForChannel(channelID int64)
|
||||
InvalidatePeerIdentityReadModel(domain.Peer)
|
||||
}
|
||||
users storepkg.UserCache
|
||||
peerIdentity bool
|
||||
log *zap.Logger
|
||||
users storepkg.UserCache
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func (n rpcProjectionVerificationNotifier) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
|
||||
if n.invalidator == nil {
|
||||
return nil
|
||||
}
|
||||
if n.peerIdentity {
|
||||
n.invalidator.InvalidatePeerIdentityReadModel(peer)
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
n.invalidator.InvalidateRPCProjectionReadModelForUser(peer.ID)
|
||||
|
|
@ -550,25 +508,6 @@ func webPagePreviewOption(cfg config.Config) filesapp.Option {
|
|||
return filesapp.WithWebPagePreview(cfg.WebPagePreviewMaxBytes, cfg.WebPagePreviewRatePerMin)
|
||||
}
|
||||
|
||||
// fastestPositiveDuration returns the smaller of a and b, treating a
|
||||
// non-positive value as "not configured" rather than as the smallest
|
||||
// possible duration -- 0 only when both are non-positive (nothing
|
||||
// configured). Used to derive the storage retention sweep's ticker cadence
|
||||
// from whichever of the shared default age and its per-category overrides
|
||||
// asks to run soonest.
|
||||
func fastestPositiveDuration(a, b time.Duration) time.Duration {
|
||||
if a <= 0 {
|
||||
return b
|
||||
}
|
||||
if b <= 0 {
|
||||
return a
|
||||
}
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func run(logger *zap.Logger) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
|
|
@ -629,15 +568,6 @@ 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),
|
||||
|
|
@ -717,8 +647,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,
|
||||
Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName,
|
||||
Logger: logger.Named("telegram-login-http"), TrustedProxyCIDRs: cfg.TelegramLoginTrustedProxyCIDRs,
|
||||
AllowHTTP: cfg.TelegramLoginAllowHTTP,
|
||||
})
|
||||
|
|
@ -731,157 +660,51 @@ 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)
|
||||
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)
|
||||
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(postgres.NewReadModelVersionStore(pool), 0, 0)
|
||||
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout))
|
||||
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()
|
||||
bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool)
|
||||
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))
|
||||
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,
|
||||
)
|
||||
secretChatIDAllocator := redisstore.NewSecretChatIDAllocator(rdb, postgres.NewSecretChatIDCounterSource(pool))
|
||||
contactStore := userprojection.NewCachedContactStore(postgres.NewContactStore(pool), 0)
|
||||
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))
|
||||
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))
|
||||
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator)
|
||||
pollStore := postgres.NewPollStore(pool)
|
||||
mediaStore := postgres.NewMediaStore(pool)
|
||||
// 头像投影缓存:所有 projector 共用 owner→头像正/负 LRU。profile_photo NOTIFY
|
||||
// 精确失效负责正常新鲜度,长 TTL 只覆盖漏通知,避免登录 ramp 周期性重查稳定负值。
|
||||
cachedPhotos := userprojection.NewCachedPhotoProviderWithMaxEntries(
|
||||
mediaStore,
|
||||
cfg.ProfilePhotoCacheTTL,
|
||||
cfg.ProfilePhotoCacheMaxEntries,
|
||||
)
|
||||
// 头像投影缓存:所有 projector 共用一层短 TTL owner→头像缓存,消除高频「返回用户」RPC
|
||||
// 每次投影对每批 owner 固定 2 次的 CurrentProfilePhotosKind PG 查询。
|
||||
cachedPhotos := userprojection.NewCachedPhotoProvider(mediaStore, userprojection.DefaultPhotoCacheTTL)
|
||||
privacyStore := privacyapp.NewCachedPrivacyStore(postgres.NewPrivacyStore(pool), 0)
|
||||
storyStore := postgres.NewStoryStore(pool)
|
||||
// Transient upload-part scratch storage always stays on local disk
|
||||
|
|
@ -951,10 +774,6 @@ func run(logger *zap.Logger) error {
|
|||
filesapp.WithUploadPartBackend(localBlobFS),
|
||||
filesapp.WithAdditionalBlobBackend(additionalBlobBackend),
|
||||
filesapp.WithSpaceGuard(spaceGuard),
|
||||
filesapp.WithMaxUploadFileBytes(cfg.StorageMaxUploadFileBytes),
|
||||
filesapp.WithStorageRetentionAges(cfg.StorageRetentionMaxAge, cfg.StorageRetentionMaxAgeByCategory, cfg.StorageRetentionMaxAgeAvatar),
|
||||
filesapp.WithStorageMaxTotalBytes(cfg.StorageMaxTotalBytes),
|
||||
filesapp.WithSecretChatDeleteFileAfterDownload(cfg.SecretChatDeleteFileAfterDownload),
|
||||
filesapp.WithMapboxMapTiles(cfg.MapboxToken, cfg.MapTileCacheDir),
|
||||
externalMediaOption(cfg),
|
||||
webPagePreviewOption(cfg),
|
||||
|
|
@ -1009,25 +828,10 @@ func run(logger *zap.Logger) error {
|
|||
zap.Int("blobs", stats.Blobs),
|
||||
)
|
||||
}
|
||||
// The official system account (777000) mirrors the operator's own Server
|
||||
// Settings -> Server identity, when set: same "default unless
|
||||
// configured" contract as the client-facing /owpengram/server-info
|
||||
// endpoint, just applied to this one built-in account's display name
|
||||
// and avatar instead of what's shown to a client adding the server.
|
||||
identityStore := identity.NewStore(cfg.IdentityDir)
|
||||
serverIdentity, err := identityStore.Get()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read server identity: %w", err)
|
||||
}
|
||||
domain.SetOfficialSystemUserDisplayName(serverIdentity.Name)
|
||||
var customSystemIcon []byte
|
||||
if iconData, _, ok := identityStore.Icon(); ok {
|
||||
customSystemIcon = iconData
|
||||
}
|
||||
if usingCustom, err := filesService.SeedOfficialSystemAvatar(ctx, customSystemIcon); err != nil {
|
||||
if seeded, err := filesService.SeedOfficialSystemAvatar(ctx); err != nil {
|
||||
return fmt.Errorf("seed official system avatar: %w", err)
|
||||
} else if usingCustom {
|
||||
logger.Info("official system account avatar seed import complete (custom Server identity icon)", zap.Int64("photo_id", domain.OfficialSystemUserPhotoID))
|
||||
} else if seeded {
|
||||
logger.Info("official system account avatar seed import complete", zap.Int64("photo_id", domain.OfficialSystemUserPhotoID))
|
||||
}
|
||||
if seeded, err := filesService.SeedBotFatherAvatar(ctx); err != nil {
|
||||
return fmt.Errorf("seed botfather avatar: %w", err)
|
||||
|
|
@ -1081,12 +885,11 @@ func run(logger *zap.Logger) error {
|
|||
Commands: adminStore,
|
||||
Restrictions: adminStore,
|
||||
})
|
||||
userProjectionFacts := userprojection.NewDurableUserProjectionFacts(
|
||||
adminService,
|
||||
readModelVersionStore,
|
||||
cfg.UserProjectionFactCacheMaxEntries,
|
||||
)
|
||||
retentionWorker := maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"),
|
||||
storageRetentionMaxAge := cfg.StorageRetentionMaxAge
|
||||
if !cfg.StorageRetentionEnable {
|
||||
storageRetentionMaxAge = 0
|
||||
}
|
||||
go maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"),
|
||||
cfg.UpdateEventRetention,
|
||||
cfg.RetentionInterval,
|
||||
cfg.RetentionBatch,
|
||||
|
|
@ -1099,39 +902,9 @@ func run(logger *zap.Logger) error {
|
|||
WithModerationRetention(moderationReportStore).
|
||||
WithUserUpdateRetention(updateEventStore).
|
||||
WithChannelUpdateRetention(channelStore).
|
||||
WithOrphanAuthKeyRetention(authKeyStore, activeSessions, cfg.OrphanAuthKeyRetention)
|
||||
// TELESRV_STORAGE_RETENTION_MODE is a single 3-way switch: at most one of
|
||||
// the orphan-only (safe) and hard (age-based, ignores live references)
|
||||
// media sweeps is ever wired in, matching "off"/"orphan"/"hard".
|
||||
// The worker's own maxAge parameter only drives how often the sweep
|
||||
// ticks (internal/app/maintenance.RetentionWorker.mediaRetentionInterval)
|
||||
// -- the real per-category cutoff math lives entirely in
|
||||
// files.Service (WithStorageRetentionAges above). Passing the raw shared
|
||||
// default here would tick as slowly as a 30-day default even when a
|
||||
// TELESRV_STORAGE_RETENTION_MAX_AGE_<CATEGORY> override asks for a much
|
||||
// shorter age (or, if the shared default is 0 -- "disabled by default,
|
||||
// only specific categories opt in" -- would disable the sweep outright,
|
||||
// since a 0 maxAge here used to gate the whole sweep off). Use the
|
||||
// fastest positive age across the shared default and every override
|
||||
// instead, so the ticker -- and the sweep-enabled gate -- reflect
|
||||
// whatever is actually configured to run soonest.
|
||||
fastestRetentionAge := fastestPositiveDuration(cfg.StorageRetentionMaxAge, cfg.StorageRetentionMaxAgeAvatar)
|
||||
for _, age := range cfg.StorageRetentionMaxAgeByCategory {
|
||||
fastestRetentionAge = fastestPositiveDuration(fastestRetentionAge, age)
|
||||
}
|
||||
switch cfg.StorageRetentionMode {
|
||||
case config.StorageRetentionModeOrphan:
|
||||
retentionWorker = retentionWorker.WithOrphanedMediaRetention(filesService, fastestRetentionAge)
|
||||
case config.StorageRetentionModeHard:
|
||||
retentionWorker = retentionWorker.WithHardMediaRetention(filesService, fastestRetentionAge)
|
||||
}
|
||||
// Active eviction is independent of TELESRV_STORAGE_RETENTION_MODE (can
|
||||
// run even when that's "off") and reuses the same media sweep ticker.
|
||||
retentionWorker = retentionWorker.WithStorageEviction(filesService, cfg.StorageEvictionEnable)
|
||||
// retentionWorker.Run itself isn't started here -- see the
|
||||
// filesService.SetRetentionPurgeNotifier call below, which must happen
|
||||
// first so the worker's very first (synchronous) sweep tick can't purge
|
||||
// blobs before there's anywhere to send the purge notice.
|
||||
WithOrphanAuthKeyRetention(authKeyStore, activeSessions, cfg.OrphanAuthKeyRetention).
|
||||
WithOrphanedMediaRetention(filesService, storageRetentionMaxAge).
|
||||
Run(ctx)
|
||||
go filesapp.NewUploadPartGCWorker(filesService, logger.Named("files").Named("upload_gc"),
|
||||
cfg.UploadPartTTL,
|
||||
cfg.UploadPartGCInterval,
|
||||
|
|
@ -1142,7 +915,7 @@ func run(logger *zap.Logger) error {
|
|||
contactsService := contacts.NewService(contactStore, userStore).Configure(
|
||||
contacts.WithPhotoProvider(cachedPhotos),
|
||||
contacts.WithPrivacyEvaluator(privacyService),
|
||||
contacts.WithAccountFreezeProvider(userProjectionFacts),
|
||||
contacts.WithAccountFreezeProvider(adminService),
|
||||
contacts.WithReadModelVersions(readModelVersionStore),
|
||||
contacts.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification),
|
||||
)
|
||||
|
|
@ -1249,33 +1022,6 @@ 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 把媒体面存活
|
||||
|
|
@ -1360,7 +1106,7 @@ func run(logger *zap.Logger) error {
|
|||
// 私聊端对端加密(Secret Chat)握手状态机 + qts 投递队列(盲中继)。
|
||||
secretChatStore := postgres.NewSecretChatStore(pool)
|
||||
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
|
||||
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore)
|
||||
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator)
|
||||
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
|
||||
// 同属进程内一次性凭据,不跨实例)。
|
||||
passkeyStore := postgres.NewPasskeyStore(pool)
|
||||
|
|
@ -1369,7 +1115,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(userProjectionFacts), 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(adminService), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification))
|
||||
privacyService.ConfigureReadModels(usersService, channelStore)
|
||||
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
|
||||
botsService.SetAIChatGenerator(aiComposeService)
|
||||
|
|
@ -1377,21 +1123,9 @@ func run(logger *zap.Logger) error {
|
|||
dialogs.WithContactStore(contactStore),
|
||||
dialogs.WithPhotoProvider(cachedPhotos),
|
||||
dialogs.WithPrivacyEvaluator(privacyService),
|
||||
dialogs.WithAccountFreezeProvider(userProjectionFacts),
|
||||
dialogs.WithAccountFreezeProvider(adminService),
|
||||
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 投影。
|
||||
|
|
@ -1399,19 +1133,10 @@ 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,
|
||||
|
|
@ -1424,23 +1149,12 @@ func run(logger *zap.Logger) error {
|
|||
messageapp.WithContactStore(contactStore),
|
||||
messageapp.WithPhotoProvider(cachedPhotos),
|
||||
messageapp.WithPrivacyEvaluator(privacyService),
|
||||
messageapp.WithAccountFreezeProvider(userProjectionFacts),
|
||||
messageapp.WithAccountFreezeProvider(adminService),
|
||||
messageapp.WithReadModelVersions(readModelVersionStore),
|
||||
messageapp.WithBotResponder(botsService),
|
||||
messageapp.WithSendPermissionChecker(adminService),
|
||||
messageapp.WithBusinessAutomation(passwordStore, businessAutomationOptions...),
|
||||
)
|
||||
// Wires the storage retention sweep's purge-notice capability now that
|
||||
// both edit-capable app services exist -- filesService was constructed
|
||||
// earlier, before either was available. Must happen before
|
||||
// retentionWorker.Run starts below: that call's first sweep tick runs
|
||||
// synchronously (maintenance.RetentionWorker.Run -> runOnce), and once a
|
||||
// document/photo's blob bytes are purged it never again matches the
|
||||
// hard-retention candidate query (see ListDocumentIDsForHardRetentionOlderThan's
|
||||
// doc comment) -- so a tick that raced ahead of this call wouldn't just
|
||||
// delay the notice, it would permanently lose it (files.SetRetentionPurgeNotifier).
|
||||
filesService.SetRetentionPurgeNotifier(messagesService, channelsService)
|
||||
go retentionWorker.Run(ctx)
|
||||
moderationService := moderationapp.NewService(
|
||||
moderationReportStore,
|
||||
moderationapp.WithMessageReaders(messagesService, channelsService),
|
||||
|
|
@ -1462,10 +1176,8 @@ func run(logger *zap.Logger) error {
|
|||
dialogStore,
|
||||
newTranslationOptions(cfg, rateLimiter, logger)...,
|
||||
)
|
||||
authService := auth.NewService(userStore, authzStore, codeStore, authKeyGetBatchStore, tempAuthKeyStore, cfg.DevAuthCode,
|
||||
authService := auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode,
|
||||
auth.WithLoginMessages(messageStore, dialogStore),
|
||||
auth.WithLoginWelcomeMessages(identityStore, cfg.WelcomeMessagePhoneTemplate, cfg.WelcomeMessageEmailTemplate),
|
||||
auth.WithLoginCodeMessageTemplate(identityStore, cfg.LoginCodeMessageTemplate),
|
||||
auth.WithLoginCodeDelivery(messageStore),
|
||||
auth.WithPasswords(passwordStore),
|
||||
auth.WithBotLogin(botStore),
|
||||
|
|
@ -1558,14 +1270,6 @@ 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,
|
||||
|
|
@ -1585,109 +1289,92 @@ 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,
|
||||
TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL,
|
||||
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
|
||||
}, rpc.Deps{
|
||||
Auth: authService,
|
||||
AuthDeliveryReports: authDeliveryReportService,
|
||||
ClientTelemetry: clientTelemetryService,
|
||||
AuthKeySessionLayers: authKeySessionLayerStore,
|
||||
ReadModelVersions: readModelVersionStore,
|
||||
UserProjectionFacts: userProjectionFacts,
|
||||
AuthKeySessionLayers: authKeyStore,
|
||||
Account: accountService,
|
||||
Privacy: privacyService,
|
||||
Help: help.NewService(helpStore, helpStore,
|
||||
help.WithMapboxToken(cfg.MapboxToken),
|
||||
help.WithEmailSignupEnable(cfg.EmailSignupEnable),
|
||||
help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
|
||||
help.WithMaxUploadFileBytes(cfg.StorageMaxUploadFileBytes),
|
||||
help.WithAccountFreezeProvider(adminService),
|
||||
),
|
||||
AppUpdates: appUpdateResolver,
|
||||
AccountFreeze: userProjectionFacts,
|
||||
AccountFreezeNotifications: adminService,
|
||||
AICompose: aiComposeService,
|
||||
Ephemeral: ephemeralService,
|
||||
EphemeralPush: ephemeralStore,
|
||||
WelcomeMessages: welcomeMessageService,
|
||||
Moderation: moderationService,
|
||||
Users: usersService,
|
||||
Usernames: usernamesService,
|
||||
BotVerifications: botVerificationService,
|
||||
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
|
||||
Updates: updatesService,
|
||||
BootstrapUpdates: bootstrapUpdateStore,
|
||||
BotAPIUpdates: botAPIUpdateStore,
|
||||
BotCallbacks: botCallbackStore,
|
||||
Contacts: contactsService,
|
||||
Dialogs: dialogsService,
|
||||
Chatlists: chatlistsService,
|
||||
Messages: messagesService,
|
||||
Translation: translationService,
|
||||
Channels: channelsService,
|
||||
Communities: communitiesService,
|
||||
Files: filesService,
|
||||
PremiumPromo: filesService,
|
||||
Bots: botsService,
|
||||
ServiceBotCallbacks: botsService,
|
||||
ServiceBotInlineResults: botsService,
|
||||
Polls: pollsapp.NewService(pollStore),
|
||||
Stories: storiesService,
|
||||
Phone: phoneService,
|
||||
SecretChats: secretChatService,
|
||||
Passkey: passkeyService,
|
||||
Themes: themeService,
|
||||
GroupCalls: groupCallsService,
|
||||
LiveStreams: liveStreamDep(liveStreamService),
|
||||
SFU: sfuService,
|
||||
TURN: turnService,
|
||||
LangPack: langPackService,
|
||||
Sessions: activeSessions,
|
||||
Metrics: metricRegistry,
|
||||
Inline: inlineRegistryStore,
|
||||
Limiter: rateLimiter,
|
||||
AccountFreeze: adminService,
|
||||
AICompose: aiComposeService,
|
||||
Ephemeral: ephemeralService,
|
||||
EphemeralPush: ephemeralStore,
|
||||
Moderation: moderationService,
|
||||
Users: usersService,
|
||||
Usernames: usernamesService,
|
||||
BotVerifications: botVerificationService,
|
||||
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
|
||||
Updates: updatesService,
|
||||
BootstrapUpdates: bootstrapUpdateStore,
|
||||
BotAPIUpdates: botAPIUpdateStore,
|
||||
BotCallbacks: botCallbackStore,
|
||||
Contacts: contactsService,
|
||||
Dialogs: dialogsService,
|
||||
Chatlists: chatlistsService,
|
||||
Messages: messagesService,
|
||||
Translation: translationService,
|
||||
Channels: channelsService,
|
||||
Communities: communitiesService,
|
||||
Files: filesService,
|
||||
PremiumPromo: filesService,
|
||||
Bots: botsService,
|
||||
ServiceBotCallbacks: botsService,
|
||||
ServiceBotInlineResults: botsService,
|
||||
Polls: pollsapp.NewService(pollStore),
|
||||
Stories: storiesService,
|
||||
Phone: phoneService,
|
||||
SecretChats: secretChatService,
|
||||
Passkey: passkeyService,
|
||||
Themes: themeService,
|
||||
GroupCalls: groupCallsService,
|
||||
LiveStreams: liveStreamDep(liveStreamService),
|
||||
SFU: sfuService,
|
||||
TURN: turnService,
|
||||
LangPack: langPackService,
|
||||
Sessions: activeSessions,
|
||||
Metrics: metricRegistry,
|
||||
Inline: inlineRegistryStore,
|
||||
Limiter: rateLimiter,
|
||||
}, logger.Named("rpc"), clock.System)
|
||||
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,
|
||||
Privacy: privacyService,
|
||||
ProfilePhotos: cachedPhotos,
|
||||
Stories: router,
|
||||
ChannelFullBots: router,
|
||||
ChannelBotMembers: channelsService,
|
||||
ChannelMediaCounts: channelsService,
|
||||
PrivateMediaCounts: messagesService,
|
||||
RPCProjections: router,
|
||||
PeerIdentities: router,
|
||||
BaseUsers: userCache,
|
||||
BotProfiles: botsService,
|
||||
AccountSettings: router,
|
||||
UserProjectionFacts: userProjectionFacts,
|
||||
ReadModelVersions: readModelVersionStore,
|
||||
ChannelRows: channelRowCache,
|
||||
ChannelMembers: channelMemberCache,
|
||||
ChannelDialogs: channelDialogCache,
|
||||
ChannelBoosts: channelBoostCache,
|
||||
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
|
||||
Dialogs: dialogsService,
|
||||
Privacy: privacyService,
|
||||
ProfilePhotos: cachedPhotos,
|
||||
Stories: router,
|
||||
ChannelFullBots: router,
|
||||
ChannelBotMembers: channelsService,
|
||||
ChannelMediaCounts: channelsService,
|
||||
PrivateMediaCounts: messagesService,
|
||||
RPCProjections: router,
|
||||
BaseUsers: userCache,
|
||||
BotProfiles: botsService,
|
||||
AccountSettings: router,
|
||||
}, 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,
|
||||
|
|
@ -1701,7 +1388,6 @@ func run(logger *zap.Logger) error {
|
|||
Photos: filesService,
|
||||
StickerSets: filesService,
|
||||
GifCatalog: filesService,
|
||||
Storage: filesService,
|
||||
Bots: botsService,
|
||||
Emoji: filesService,
|
||||
Moderation: moderationService,
|
||||
|
|
@ -1753,10 +1439,9 @@ func run(logger *zap.Logger) error {
|
|||
if notifier, ok := any(router).(botverificationapp.PeerNotifier); ok {
|
||||
botVerificationService.SetPeerNotifier(compositeBotVerificationNotifier{
|
||||
cache: rpcProjectionVerificationNotifier{
|
||||
invalidator: router,
|
||||
users: userCache,
|
||||
peerIdentity: true,
|
||||
log: verificationLogger,
|
||||
invalidator: router,
|
||||
users: userCache,
|
||||
log: verificationLogger,
|
||||
},
|
||||
edge: notifier,
|
||||
})
|
||||
|
|
@ -1773,15 +1458,9 @@ func run(logger *zap.Logger) error {
|
|||
// all/selected users) are delivered from the same kind of durable outbox as
|
||||
// applicant notifications above: an admin creating one for every user must
|
||||
// not wait on however long sending to all of them takes.
|
||||
go broadcastapp.NewWorker(broadcastService, broadcastapp.WorkerConfig{
|
||||
Interval: cfg.BroadcastWorkerInterval,
|
||||
Lease: cfg.BroadcastWorkerLease,
|
||||
MaterializeBatch: cfg.BroadcastWorkerMaterializeBatch,
|
||||
DeliveryBatch: cfg.BroadcastWorkerBatch,
|
||||
}, logger.Named("broadcast").Named("delivery")).Run(ctx)
|
||||
moderationActionOptions := []moderationapp.ActionExecutorOption{
|
||||
moderationapp.WithAccountDeletionNotifier(router),
|
||||
}
|
||||
go broadcastapp.NewWorker(broadcastService, logger.Named("broadcast").Named("delivery"),
|
||||
cfg.BroadcastWorkerInterval, cfg.BroadcastWorkerBatch).Run(ctx)
|
||||
moderationActionOptions := []moderationapp.ActionExecutorOption{}
|
||||
if cfg.PublicLinkWebAddr != "" {
|
||||
moderationActionOptions = append(
|
||||
moderationActionOptions,
|
||||
|
|
@ -1810,7 +1489,6 @@ 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)
|
||||
|
|
@ -1818,7 +1496,6 @@ 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)
|
||||
|
|
@ -1874,9 +1551,8 @@ func run(logger *zap.Logger) error {
|
|||
DC: cfg.DC,
|
||||
StrictDC: cfg.StrictDCCheck,
|
||||
RSAKey: rsaKey,
|
||||
IdentityDir: cfg.IdentityDir,
|
||||
LayerRPC: router,
|
||||
AuthKeys: authKeyGetBatchStore,
|
||||
AuthKeys: authKeyStore,
|
||||
ActiveSessions: activeSessions,
|
||||
Metrics: metricRegistry,
|
||||
ObfuscatedTCP: true,
|
||||
|
|
@ -1891,8 +1567,6 @@ 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,
|
||||
|
|
@ -1911,23 +1585,6 @@ func run(logger *zap.Logger) error {
|
|||
zap.Uint("schema_version", migrationStatus.Version),
|
||||
zap.String("blob_backend", "localfs"),
|
||||
)
|
||||
// Picks up a pending "please bounce the admin panel" request left
|
||||
// by cmd/telesrv-admin's Restart/Update (internal/procctl) --
|
||||
// see HandlePendingAdminRestart's doc comment for why this
|
||||
// process (the new one, already up) is the safe place to do
|
||||
// that from, not the admin panel doing it to itself. Repo root
|
||||
// is cwd, matching cmd/telesrv-admin's own convention; a no-op,
|
||||
// not an error, when no restart was requested or the repo
|
||||
// layout (bin/, .server_panel.json) isn't present.
|
||||
go func() {
|
||||
if root, err := os.Getwd(); err == nil {
|
||||
if restarted, err := procctl.NewManager(root).HandlePendingAdminRestart(ctx); err != nil {
|
||||
logger.Warn("admin panel auto-restart failed", zap.Error(err))
|
||||
} else if restarted {
|
||||
logger.Info("admin panel restarted after server restart/update")
|
||||
}
|
||||
}
|
||||
}()
|
||||
},
|
||||
})
|
||||
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
|
||||
|
|
|
|||
|
|
@ -2,43 +2,10 @@ package main
|
|||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
)
|
||||
|
||||
// TestFastestPositiveDurationIgnoresNonPositiveValues guards a real reported
|
||||
// bug: the storage retention sweep's ticker cadence used to be driven purely
|
||||
// by the shared TELESRV_STORAGE_RETENTION_MAX_AGE default (e.g. 30 days),
|
||||
// even when a much shorter TELESRV_STORAGE_RETENTION_MAX_AGE_<CATEGORY>
|
||||
// override was configured -- so a category set to "1 minute" would still
|
||||
// only actually get swept on the shared default's own slow cadence (or, if
|
||||
// the shared default was 0, would disable the sweep outright). The worker
|
||||
// must be handed the fastest positive age across the default and every
|
||||
// override instead.
|
||||
func TestFastestPositiveDurationIgnoresNonPositiveValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
a, b time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{"both positive, a smaller", 30 * 24 * time.Hour, time.Minute, time.Minute},
|
||||
{"both positive, b smaller", time.Minute, 30 * 24 * time.Hour, time.Minute},
|
||||
{"a zero (disabled), b positive", 0, time.Minute, time.Minute},
|
||||
{"a positive, b zero (disabled)", time.Minute, 0, time.Minute},
|
||||
{"a negative, b positive", -time.Hour, time.Minute, time.Minute},
|
||||
{"both zero (nothing configured)", 0, 0, 0},
|
||||
{"both negative", -time.Hour, -time.Minute, -time.Minute},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := fastestPositiveDuration(c.a, c.b); got != c.want {
|
||||
t.Fatalf("fastestPositiveDuration(%v, %v) = %v, want %v", c.a, c.b, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginRPCDependencyPreservesDisabledNil(t *testing.T) {
|
||||
var disabled *telegramloginapp.Service
|
||||
if dependency := telegramLoginRPCDependency(disabled); dependency != nil {
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows
|
||||
|
||||
package main
|
||||
|
||||
func processCPUSeconds() (float64, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
//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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
//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
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
//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
|
||||
}
|
||||
|
|
@ -70,12 +70,7 @@ services:
|
|||
# 桶由 telesrv 自己在启动时按需创建(internal/app/files/blobs3.go 的 NewS3FS),
|
||||
# 这里不需要额外的 mc 初始化容器。控制台(9001)仅用于本地调试查看已存对象。
|
||||
minio:
|
||||
# quay.io, not Docker Hub: MinIO stopped serving minio/minio to anonymous
|
||||
# pulls there, so `docker compose up` failed on a fresh machine with
|
||||
# "pull access denied ... or may require 'docker login'" -- for every tag,
|
||||
# not just latest. quay.io/minio/minio is MinIO's own registry and needs
|
||||
# no login.
|
||||
image: quay.io/minio/minio:latest
|
||||
image: minio/minio:latest
|
||||
container_name: ${TELESRV_DOCKER_PREFIX:-owpengram}-minio
|
||||
command: ["server", "/data", "--console-address", ":9001"]
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
# 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 +0,0 @@
|
|||
LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBeEYvLzBNMCsvNVB6Z2ROYWdUWCtKK2RKZ3I3NVpDVHVpRzhpNHg3WXdtSkYramlPCkdDam03WDdCTENhTWMxK2hPWllETDMrR3ZsZS9BS3lrVzFxb3VhQ0pNVngvSCsybDhMRlhMZWxaMlBMYXdUYjgKQTdCbFRxV3pMM2RiNUJ1Z01OV3ppTDlUdWhSOEluMWJ3S1kwN1FWcFI5aW41empBc0FHTEJrK21HdDBEblZ5TQpmMVhvcDJsTENGTm1tMEY0eWtjQWVhTENDSVBiR1dkZGxpTFk4eEVFaEk0R08ybDFVM2taTXdJT2RPbkFHSkZ0CmdVQW9UZStGSFI2RjFzOWFkQ1ZaQjF0ZUwvaGY5UitXbWVrSnd5Z1Z6ME1ZRUg3eTZVNDlUNDUrL1c3T0Y2WDYKZzBXMGoxdVNTcnNZNHFON3R3eGJUYWQ5emRHWjd5cys5ditQdVFJREFRQUJBb0lCQUFqdXFPOHhkczBmU0xNKwpEdDdUdXVUTHcyODhDcElBa0EwS3FSYVZuNXh2NWVqMHk1blR1blZSRDY1WGJvb003b04xREY0THVmQk1nM2FmClk3WjRFRGFwVTdRNEZkdzQ3aFJkcks1ODc4WkxmYUhPUTNaVGZyZ3VGMUZ3WjNDZnhSQ1RsOS8vZStwNTVnK1gKamlYY0tZb2lkZUI3dlY5cUdIR3BFRTdRTHFrSUVpbk1FV05hQjh1dGN0SDdUWGRXYTRweWZJR2lQckhNTjJ6SApoRjQwSWI3bkpBNmtodHpzTkNEU0Q5WG5ibEVORW9kMUU1Z1JzalE5ZkdzaGRCdHBEc1hyTEJGTDdLTUREb1FtCmN6cnQvS3hsWk1wYnRPZno2dWE1ZUtFQkJUdE51dG1WY3AxcTl5K2NRcFBaem5ZaVJUTCtPSnpWZ2Z1SE9PLzAKZWEyai93RUNnWUVBNkk2ZitFTG84QVA3UTRXMHFzc3VYS3ZIRUYvUkVFZngyTHVlaDBsclVoVlIzSWdqYWtGYwpiVFJsTEVRSzhRTmJEQW1OTXVIckVabmptR3U3bnkzek45NHNsQy95Y0k1cGQ5aFFHVndPMjJlcjlvMitNOXcyCkpwQjRRZjhjOFNLL3BIUU45K2pPdEJ4VjJkcmJmZTBvVW9LRXRZeUQ1Y2J0akZUM3lmZUEyQ0VDZ1lFQTJDdW8KUzg0MWtWcHB4MUt3cEw0aTFmZ0dRUHlrYUtyRDRvR1pyWWd2MkZ4VzU1aS9xNUZjWUw2ZkUzN0phTTFwby9SQgp0bkhnMzVOYW5nL3l1Wlh6NkViNEswQ3lMKzhMdWhDSWI2UHhEdnF5Q1hia1hUd0hTVlJBbkdFTVNOM3phLzdGCkZDSkYxQUJXZzhqbEtSZEEvbFJ3cW84UDhaZ0JlQk1xcEtaaDVKa0NnWUJoQ0ltazI3NDN6MkY2dGdKQk5VL2QKNk9yQllVbHBJcXU5ZytOTWpZelREZ1EvSVNxdHZpSGppdllmOXpBZGlnbm1SdUg4ZGhsUUdjYkdKVVYrMEh4bwpOaktoampQNVZPS2ExODNzRnVZNEU5VERwamJUaXJHcGU2UkIzVUZsTjl1QXNjL1dQZlJwWUYxTjdpeWhLV0FtCnRVRE1RNW9ST09TTEpqVFJ0NHl5SVFLQmdRQ1RmeTVsRXYyd0FQMzk5K2o1YjVhN1luRjU5Q2lHRmtaMERiUDcKR05wMGlZVHVuMlhndmQxSFVhbWZGcnA4blBRQTM4L2FtZGN6RmdzVm9KSWdtVFdFZnJBa2F3OXA3M1NUNzJYNApydWJ6THBFK0xmWmh1MnpKVndpQzZ5RURzeFc5MFdkTmRwa29yMVpZczBIUmlNRmJCK2ljSitOY0dEaWdZb3VOCkxzM0t1UUtCZ1FDVEF4Vk03ek5QZ3NmZnVkWlA3Rk1ueTIwTE1SNitGUW83eFJ6NUVsWWFDVmdYTTFOSDRLL3IKeUZrT0NHaE9ONkkvTVVOQlB4V0xFdVpCUmZ5cmx5M2JwM2o3UjYvaExYNGZOeXc5QjNHUXVWeUNIRDJENHRvZAo2SjVWby9Fc2VxcGVlS2E2Q3YvWTJIVkIwa2ZHdzFWQ0MvZ00wMUw4dWVkM2hzcjJMRDJrQ3c9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
-----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-----
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
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:
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
#!/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,9 +2648,7 @@ 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,
|
||||
CONSTRAINT secret_chats_nonzero_id CHECK ((chat_id <> 0)),
|
||||
CONSTRAINT secret_chats_random_id_is_chat_id CHECK ((chat_id = random_id))
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
|
@ -4526,6 +4524,13 @@ 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: -
|
||||
--
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
-- No-op: see 0181_sticker_set_system_key_unique.up.sql. The real schema
|
||||
-- change (and its reversal) lives in
|
||||
-- 20260901000006_sticker_set_system_key_unique.down.sql.
|
||||
SELECT 1;
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
-- No-op placeholder. This migration's real content (sticker_sets.system_key
|
||||
-- uniqueness) already applies via 20260901000006_sticker_set_system_key_unique
|
||||
-- (identical SQL, applied earlier under our fork's own timestamp-based
|
||||
-- migration numbering). The version number 181 itself must still exist as a
|
||||
-- migration, though: internal/store/postgres/postgres.go's Migrate() calls
|
||||
-- m.Migrate(phoneIdentityPredecessorVersion) (=181) to step a fresh database
|
||||
-- to exactly this version before running the Go-side phone-identity
|
||||
-- canonicalization pass, and golang-migrate requires that version to be a
|
||||
-- real, reachable migration file. Do not reapply the sticker_sets change here
|
||||
-- -- 20260901000006 already owns it, and repeating it would fail on an
|
||||
-- already-unique index for any install that runs both.
|
||||
SELECT 1;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
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();
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
-- 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;
|
||||
$$;
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
-- 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();
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
DROP TABLE IF EXISTS welcome_messages;
|
||||
DROP TABLE IF EXISTS welcome_message_peers;
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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
|
||||
)
|
||||
);
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
DROP TABLE IF EXISTS welcome_message_deliveries;
|
||||
DROP SEQUENCE IF EXISTS welcome_message_join_event_id_seq;
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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);
|
||||
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