Compare commits
101 commits
6649b70d5e
...
669e515061
| Author | SHA1 | Date | |
|---|---|---|---|
| 669e515061 | |||
| 33e22308c6 | |||
| 6435406690 | |||
| 1f6f25074a | |||
| 4ca35d2000 | |||
| f823b2cb74 | |||
| 61f6685603 | |||
| 3ae27c435d | |||
| caa4d955e5 | |||
| eb23afdd39 | |||
| 3289882dbd | |||
| f1c24e483c | |||
| 60537342d3 | |||
| d6d3be0070 | |||
| 9ed8590264 | |||
| 2bdb1ecf37 | |||
| 22846e340f | |||
| f5a0e770c7 | |||
| 7aab677ce7 | |||
| 213b7c5234 | |||
| 494e76dc70 | |||
| 94b3113a37 | |||
| 5a89a83caf | |||
| 2014c98386 | |||
| 4f7d619955 | |||
| 0991207802 | |||
| c8380b4257 | |||
| b047a90271 | |||
| 012f8a8d0e | |||
| eb7cdd1a77 | |||
| c7a77c23c8 | |||
| f0bf315bf3 | |||
| 57b7829a9e | |||
| d4fe056854 | |||
| 4d781a52bb | |||
| db3fbe6015 | |||
| 5afafc60c4 | |||
| f474a360d7 | |||
| 196f90f9b1 | |||
| 09e2d24a4a | |||
|
|
4e148052b6 | ||
|
|
d8d6788a1c | ||
|
|
cd59cd8d28 | ||
|
|
e4debd6b7d | ||
|
|
22b308483d | ||
|
|
7c0639cf6a | ||
|
|
93e5d4229d | ||
|
|
a98c0beffc | ||
|
|
559769af88 | ||
|
|
6d026e16f2 | ||
|
|
27c8e9a434 | ||
|
|
03de33c418 | ||
|
|
4224b9d15c | ||
|
|
7ad68c3983 | ||
|
|
c04a8ddc6a | ||
|
|
2f1818d656 | ||
|
|
a9e758b712 | ||
|
|
274bdc8bac | ||
|
|
979d27ec7a | ||
|
|
e59d85cf57 | ||
|
|
7b788408b9 | ||
|
|
d5eb77e2d6 | ||
|
|
2584380a80 | ||
|
|
e240bfbbfa | ||
|
|
5c16371ae0 | ||
|
|
eb69c8500c | ||
|
|
db40f100cd | ||
|
|
f7b3af48de | ||
|
|
87173ae43f | ||
|
|
ae2cc3ba90 | ||
|
|
280321b902 | ||
|
|
e48160ac3a | ||
|
|
5f94d3e028 | ||
|
|
62849c532e | ||
|
|
87cebec9bd | ||
|
|
aeaf3f4596 | ||
|
|
b65ad60fe8 | ||
|
|
3fef764ece | ||
|
|
508d120bd3 | ||
|
|
a20f5b8b33 | ||
|
|
5a7618aaba | ||
|
|
a00f6ad814 | ||
|
|
9cf53449fd | ||
|
|
ec888d3a26 | ||
|
|
863ae2e990 | ||
|
|
8ef2b58bf9 | ||
|
|
e6bfe2d444 | ||
|
|
70c0ba44f0 | ||
|
|
95e62c2d77 | ||
|
|
d2f4e11390 | ||
|
|
62d8b53030 | ||
|
|
7cd1f64d0d | ||
|
|
e8dc967e6a | ||
|
|
21a0856587 | ||
|
|
79c64ee916 | ||
|
|
ef325f31da | ||
|
|
f3c4e3c60b | ||
|
|
1c9a192a96 | ||
|
|
80bc8d352c | ||
|
|
66f9c0bc1e | ||
|
|
902f3606c2 |
919 changed files with 87752 additions and 6886 deletions
5
.containerignore
Normal file
5
.containerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.git
|
||||
bin
|
||||
*.pem
|
||||
.env
|
||||
.env.*
|
||||
42
.dockerignore
Normal file
42
.dockerignore
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
.git
|
||||
.github
|
||||
.codex-tmp
|
||||
.gocache
|
||||
.tdesktop-e2e
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Local builds, caches, and runtime state.
|
||||
bin
|
||||
dist
|
||||
logs
|
||||
tmp
|
||||
coverage.*
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
**/node_modules
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
|
||||
# Deployment credentials stay out of the build context. The explicitly
|
||||
# published test RSA fixture below is the only private-key exception.
|
||||
.env
|
||||
.env.*
|
||||
**/.env
|
||||
**/.env.*
|
||||
codex.local
|
||||
secrets
|
||||
**/secrets
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Runtime data is excluded except for the tracked language-pack seed.
|
||||
data/*
|
||||
!data/langpack/
|
||||
!data/langpack/**
|
||||
|
||||
# Deployment-local state and overrides.
|
||||
deploy/docker/.env
|
||||
deploy/docker/backups
|
||||
deploy/docker/overrides
|
||||
139
.env.example
139
.env.example
|
|
@ -40,6 +40,24 @@ 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=
|
||||
|
|
@ -82,17 +100,24 @@ 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). Use your real domain once you have one, e.g. https://example.com.
|
||||
TELESRV_PUBLIC_BASE_URL=http://127.0.0.1
|
||||
# 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
|
||||
# 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) and the product name shown
|
||||
# on public landing pages.
|
||||
TELESRV_PUBLIC_WEB_BASE_URL=https://web.telesrv.net
|
||||
# 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.
|
||||
TELESRV_PUBLIC_APP_NAME=OwpenGram
|
||||
# Where the "Download" button on public pages links to.
|
||||
TELESRV_PUBLIC_DOWNLOAD_URL=https://owpengram.org
|
||||
|
|
@ -100,6 +125,11 @@ 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.
|
||||
|
||||
|
|
@ -117,8 +147,13 @@ 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.
|
||||
TELESRV_ADMIN_UI_ADDR=127.0.0.1:2600
|
||||
# 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
|
||||
# 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
|
||||
|
|
@ -269,14 +304,63 @@ 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 live in the Advanced section below.
|
||||
# fill up. Thresholds are the three fields right below.
|
||||
TELESRV_STORAGE_LOW_SPACE_GUARD_ENABLE=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
|
||||
# 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
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
|
@ -312,7 +396,7 @@ TELESRV_MTPROTO_RPC_MAX_INFLIGHT=32
|
|||
TELESRV_MTPROTO_RPC_QUEUE_SIZE=64
|
||||
TELESRV_MTPROTO_RPC_TIMEOUT=30s
|
||||
TELESRV_MTPROTO_RPC_GLOBAL_WORKERS=256
|
||||
TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=8192
|
||||
TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=32768
|
||||
TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES=536870912
|
||||
# Metadata-only rpc_result receipt budgets: global >= auth >= session. ACK deletes immediately;
|
||||
# 331s is only the no-ACK horizon. Payloads live solely in the logical-session outbound budget.
|
||||
|
|
@ -410,23 +494,14 @@ 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
|
||||
|
||||
# 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.
|
||||
# How often the cached free-space/usage gauge behind the low-space guard
|
||||
# (thresholds now live in the Storage & Media section 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
|
||||
|
|
@ -465,6 +540,10 @@ TELESRV_VERIFICATION_BOT_RATE_WINDOW=1m
|
|||
# rows. Interval must be positive; batch must be 1..500.
|
||||
TELESRV_VERIFICATION_NOTIFY_INTERVAL=15s
|
||||
TELESRV_VERIFICATION_NOTIFY_BATCH=50
|
||||
TELESRV_BROADCAST_WORKER_INTERVAL=3s
|
||||
TELESRV_BROADCAST_WORKER_LEASE=30s
|
||||
TELESRV_BROADCAST_MATERIALIZE_BATCH=200
|
||||
TELESRV_BROADCAST_DELIVERY_BATCH=50
|
||||
# Applications one applicant may keep open at once; 0 disables the cap, maximum
|
||||
# is 50.
|
||||
TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER=3
|
||||
|
|
|
|||
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# 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
Normal file
130
.github/workflows/build.yml
vendored
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
name: Build and Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: CI
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.goos }}/${{ matrix.goarch }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: cmd/telesrv-admin/web/package-lock.json
|
||||
|
||||
- name: Build admin web assets
|
||||
working-directory: cmd/telesrv-admin/web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Download Go modules
|
||||
run: go mod download
|
||||
|
||||
- name: Build binaries
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
run: |
|
||||
mkdir -p dist
|
||||
|
||||
suffix=""
|
||||
if [ "${GOOS}" = "windows" ]; then
|
||||
suffix=".exe"
|
||||
fi
|
||||
|
||||
go build \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o "dist/gramsrv-${GOOS}-${GOARCH}${suffix}" \
|
||||
./cmd/telesrv
|
||||
|
||||
go build \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o "dist/gramsrv-admin-${GOOS}-${GOARCH}${suffix}" \
|
||||
./cmd/telesrv-admin
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gramsrv-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: dist/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
needs:
|
||||
- ci
|
||||
- build
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: gramsrv-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate combined checksums
|
||||
working-directory: dist
|
||||
run: |
|
||||
sha256sum gramsrv-* > SHA256SUMS
|
||||
|
||||
- name: Publish GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release create "${{ github.ref_name }}" \
|
||||
dist/* \
|
||||
--title "${{ github.ref_name }}" \
|
||||
--generate-notes
|
||||
180
.github/workflows/ci.yml
vendored
Normal file
180
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
go-test:
|
||||
name: Go tests
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Download Go modules
|
||||
run: go mod download
|
||||
|
||||
- name: Test
|
||||
run: go test ./... -count=1
|
||||
|
||||
admin-web:
|
||||
name: Admin web build
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: cmd/telesrv-admin/web/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: cmd/telesrv-admin/web
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
working-directory: cmd/telesrv-admin/web
|
||||
run: npm run build
|
||||
|
||||
grammystore:
|
||||
name: Grammy store bot
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: cmd/bots/grammystore/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: cmd/bots/grammystore
|
||||
run: npm ci
|
||||
|
||||
- name: Check syntax
|
||||
working-directory: cmd/bots/grammystore
|
||||
run: npm run check
|
||||
|
||||
- name: Test
|
||||
working-directory: cmd/bots/grammystore
|
||||
run: npm test
|
||||
|
||||
docker-smoke:
|
||||
name: Docker main topology smoke
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Generate isolated environment
|
||||
run: ./scripts/new-docker-env.sh --advertise-ip 127.0.0.1
|
||||
|
||||
- name: Validate deployment inputs
|
||||
run: |
|
||||
docker compose version
|
||||
sh -n scripts/new-docker-env.sh
|
||||
bash -n scripts/start-docker.sh
|
||||
sh -n deploy/docker/docker-entrypoint.sh
|
||||
compose=(docker compose -p telesrv-main-ci --project-directory deploy/docker --env-file deploy/docker/.env -f deploy/docker/compose.yaml)
|
||||
bridge=("${compose[@]}" -f deploy/docker/compose.bridge-network.yaml)
|
||||
"${compose[@]}" config --quiet
|
||||
"${compose[@]}" config --format json | python3 -c 'import json,sys; s=json.load(sys.stdin)["services"]; assert s["server"].get("network_mode") == "host"; assert s["admin"].get("network_mode") == "host"; assert len(s["server"].get("ports", [])) == 0; assert len(s["admin"].get("ports", [])) == 0; assert str(s["server"]["environment"]["TELESRV_TURN_RELAY_MAX_PORT"]) == "12999"'
|
||||
"${bridge[@]}" config --quiet
|
||||
"${bridge[@]}" config --format json | python3 -c 'import json,sys; c=json.load(sys.stdin); s=c["services"]; assert s["server"].get("network_mode") != "host"; assert s["admin"].get("network_mode") != "host"; assert len(s["server"].get("ports", [])) == 69; assert len(s["admin"].get("ports", [])) == 1; assert "admin_host_access" in s["admin"]["networks"]; assert not c["networks"]["admin_host_access"].get("internal", False); assert str(s["server"]["environment"]["TELESRV_TURN_RELAY_MAX_PORT"]) == "12563"'
|
||||
|
||||
- name: Validate PowerShell launchers
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
[void][System.Management.Automation.Language.Parser]::ParseFile("scripts/new-docker-env.ps1", [ref]$tokens, [ref]$errors)
|
||||
if ($errors.Count -gt 0) { $errors | ForEach-Object { Write-Error $_ }; exit 1 }
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
[void][System.Management.Automation.Language.Parser]::ParseFile("scripts/start-docker.ps1", [ref]$tokens, [ref]$errors)
|
||||
if ($errors.Count -gt 0) { $errors | ForEach-Object { Write-Error $_ }; exit 1 }
|
||||
|
||||
- name: Build application images
|
||||
run: |
|
||||
compose=(docker compose -p telesrv-main-ci --project-directory deploy/docker --env-file deploy/docker/.env -f deploy/docker/compose.yaml)
|
||||
"${compose[@]}" build --pull server admin
|
||||
|
||||
- name: Start and wait for readiness
|
||||
run: docker compose -p telesrv-main-ci --project-directory deploy/docker --env-file deploy/docker/.env -f deploy/docker/compose.yaml up -d --no-build --wait --wait-timeout 600
|
||||
|
||||
- name: Verify runtime and media listeners
|
||||
run: |
|
||||
compose=(docker compose -p telesrv-main-ci --project-directory deploy/docker --env-file deploy/docker/.env -f deploy/docker/compose.yaml)
|
||||
for service in server admin; do
|
||||
container_id="$("${compose[@]}" ps --quiet "$service")"
|
||||
test -n "$container_id"
|
||||
test "$(docker inspect --format '{{.Config.User}}' "$container_id")" = "10001:10001"
|
||||
test "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' "$container_id")" = "true"
|
||||
test "$(docker inspect --format '{{json .HostConfig.CapDrop}}' "$container_id")" = '["ALL"]'
|
||||
test "$(docker inspect --format '{{.HostConfig.PidsLimit}}' "$container_id")" = "1024"
|
||||
test "$(docker inspect --format '{{json .HostConfig.SecurityOpt}}' "$container_id")" = '["no-new-privileges:true"]'
|
||||
test "$(docker inspect --format '{{.HostConfig.NetworkMode}}' "$container_id")" = "host"
|
||||
done
|
||||
curl --fail --silent --show-error http://127.0.0.1:2401/healthz | grep -qx ok
|
||||
curl --fail --silent --show-error http://127.0.0.1:2600/ >/dev/null
|
||||
timeout 5 bash -c 'exec 3<>/dev/tcp/127.0.0.1/2400'
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
|
||||
transaction_id = os.urandom(12)
|
||||
request = struct.pack("!HHI12s", 0x0001, 0, 0x2112A442, transaction_id)
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
|
||||
client.settimeout(5)
|
||||
client.sendto(request, ("127.0.0.1", 12400))
|
||||
response, _ = client.recvfrom(2048)
|
||||
message_type, _, cookie = struct.unpack("!HHI", response[:8])
|
||||
assert message_type == 0x0101, hex(message_type)
|
||||
assert cookie == 0x2112A442, hex(cookie)
|
||||
assert response[8:20] == transaction_id
|
||||
PY
|
||||
server_logs="$("${compose[@]}" logs --no-color server)"
|
||||
case "$server_logs" in *"sfu listening"*) ;; *) echo "Embedded SFU did not become ready" >&2; exit 1 ;; esac
|
||||
case "$server_logs" in *"turn listening"*) ;; *) echo "Embedded TURN did not become ready" >&2; exit 1 ;; esac
|
||||
case "$server_logs" in *"live stream rtmp ingest listening"*) ;; *) echo "RTMP listener did not become ready" >&2; exit 1 ;; esac
|
||||
|
||||
- name: Show logs on failure
|
||||
if: failure()
|
||||
run: docker compose -p telesrv-main-ci --project-directory deploy/docker --env-file deploy/docker/.env -f deploy/docker/compose.yaml logs --no-color --tail 200
|
||||
|
||||
- name: Remove isolated stack
|
||||
if: always()
|
||||
run: docker compose -p telesrv-main-ci --project-directory deploy/docker --env-file deploy/docker/.env -f deploy/docker/compose.yaml down --volumes --remove-orphans
|
||||
76
.github/workflows/container-images.yml
vendored
Normal file
76
.github/workflows/container-images.yml
vendored
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
name: Publish main container images (manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: containers-main-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish ${{ matrix.role }}
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- role: server
|
||||
target: server-test
|
||||
- role: admin
|
||||
target: admin
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Generate image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}/${{ matrix.role }}
|
||||
tags: |
|
||||
type=raw,value=main
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Set build date
|
||||
id: build
|
||||
shell: bash
|
||||
run: echo "date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and publish
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: ${{ matrix.target }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VCS_REF=${{ github.sha }}
|
||||
VCS_BRANCH=${{ github.ref_name }}
|
||||
VCS_TREE_STATE=clean
|
||||
BUILD_DATE=${{ steps.build.outputs.date }}
|
||||
cache-from: type=gha,scope=main-${{ matrix.role }}
|
||||
cache-to: type=gha,mode=max,scope=main-${{ matrix.role }}
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -10,6 +10,9 @@ 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
|
||||
|
|
@ -35,6 +38,10 @@ 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/
|
||||
|
|
|
|||
32
Containerfile
Normal file
32
Containerfile
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
FROM docker.io/library/golang:1.25 AS build
|
||||
# Build metadata for telesrv's startup log (git_commit/git_branch/... in
|
||||
# cmd/telesrv/buildinfo.go). .containerignore excludes .git, so go build's
|
||||
# automatic VCS stamping sees no repo; pass these in explicitly, e.g.:
|
||||
# podman build \
|
||||
# --build-arg GIT_COMMIT="$(git rev-parse HEAD)" \
|
||||
# --build-arg GIT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" \
|
||||
# --build-arg GIT_TREE_STATE="$(git diff --quiet && echo clean || echo dirty)" \
|
||||
# --build-arg BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
# -t owpengram-server -f Containerfile .
|
||||
ARG GIT_COMMIT=unknown
|
||||
ARG GIT_BRANCH=unknown
|
||||
ARG GIT_TREE_STATE=unknown
|
||||
ARG BUILD_TIME=unknown
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags "-X main.gitCommit=${GIT_COMMIT} -X main.gitBranch=${GIT_BRANCH} -X main.gitTreeState=${GIT_TREE_STATE} -X main.buildTime=${BUILD_TIME}" \
|
||||
-o /out/gramsrv ./cmd/telesrv
|
||||
RUN CGO_ENABLED=0 go build -trimpath -o /out/telesrv-admin ./cmd/telesrv-admin
|
||||
RUN CGO_ENABLED=0 go build -trimpath -o /out/createuser ./cmd/createuser
|
||||
|
||||
FROM docker.io/library/alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates tzdata ffmpeg
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/gramsrv /app/gramsrv
|
||||
COPY --from=build /out/telesrv-admin /app/telesrv-admin
|
||||
COPY --from=build /out/createuser /app/createuser
|
||||
COPY --from=build /src/data/langpack /app/data/langpack
|
||||
COPY --from=build /src/data/sticker-seed /app/data/sticker-seed
|
||||
EXPOSE 2398 2600
|
||||
ENTRYPOINT ["/app/gramsrv"]
|
||||
91
Dockerfile
Normal file
91
Dockerfile
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG GO_IMAGE=golang:1.25-alpine@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59
|
||||
ARG ALPINE_IMAGE=alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
|
||||
|
||||
FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS build-base
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apk add --no-cache ca-certificates git
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
|
||||
COPY cmd/ ./cmd/
|
||||
COPY deploy/ ./deploy/
|
||||
COPY internal/ ./internal/
|
||||
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
FROM build-base AS build-server
|
||||
ARG VCS_REF=unknown
|
||||
ARG VCS_BRANCH=unknown
|
||||
ARG VCS_TREE_STATE=unknown
|
||||
ARG BUILD_DATE=unknown
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -trimpath \
|
||||
-ldflags="-s -w -X main.gitCommit=${VCS_REF} -X main.gitBranch=${VCS_BRANCH} -X main.gitTreeState=${VCS_TREE_STATE} -X main.buildTime=${BUILD_DATE}" \
|
||||
-o /out/telesrv ./cmd/telesrv
|
||||
|
||||
FROM build-base AS build-admin
|
||||
RUN apk add --no-cache nodejs npm
|
||||
WORKDIR /src/cmd/telesrv-admin/web
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci && npm run build
|
||||
WORKDIR /src
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -trimpath -ldflags="-s -w" -o /out/telesrv-admin ./cmd/telesrv-admin
|
||||
|
||||
FROM ${ALPINE_IMAGE} AS runtime-base
|
||||
|
||||
ARG VCS_REF=unknown
|
||||
ARG BUILD_DATE=unknown
|
||||
|
||||
LABEL org.opencontainers.image.title="gramsrv" \
|
||||
org.opencontainers.image.description="Telegram-like MTProto server" \
|
||||
org.opencontainers.image.source="https://github.com/iamxvbaba/gramsrv" \
|
||||
org.opencontainers.image.revision="${VCS_REF}" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}"
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
&& addgroup -S -g 10001 telesrv \
|
||||
&& adduser -S -D -H -u 10001 -G telesrv telesrv \
|
||||
&& install -d -o telesrv -g telesrv -m 0750 /app /var/lib/telesrv
|
||||
|
||||
COPY --chmod=0555 deploy/docker/docker-entrypoint.sh /usr/local/bin/telesrv-container-entrypoint
|
||||
|
||||
WORKDIR /app
|
||||
USER 10001:10001
|
||||
ENTRYPOINT ["/usr/local/bin/telesrv-container-entrypoint"]
|
||||
|
||||
FROM runtime-base AS server
|
||||
USER root
|
||||
RUN apk add --no-cache ffmpeg openssl \
|
||||
&& install -d -o telesrv -g telesrv -m 0750 \
|
||||
/var/lib/telesrv/blobs \
|
||||
/var/lib/telesrv/blob-staging \
|
||||
/var/lib/telesrv/maptiles \
|
||||
/var/lib/telesrv/livestream
|
||||
COPY --from=build-server /out/telesrv /usr/local/bin/telesrv
|
||||
COPY --chown=telesrv:telesrv data/langpack/ /usr/share/telesrv/langpack/
|
||||
USER 10001:10001
|
||||
EXPOSE 2398 2400 2401 2599 12399/udp 12400/udp
|
||||
CMD ["telesrv"]
|
||||
|
||||
FROM server AS server-test
|
||||
USER root
|
||||
RUN install -d -o telesrv -g telesrv -m 0755 /usr/share/telesrv/keys
|
||||
COPY --chown=telesrv:telesrv --chmod=0444 deploy/docker/assets/test-server-rsa.pub /usr/share/telesrv/keys/test-server-rsa.pub
|
||||
COPY --chown=telesrv:telesrv --chmod=0444 deploy/docker/assets/test-server-rsa.pem.b64 /usr/share/telesrv/keys/test-server-rsa.pem.b64
|
||||
USER 10001:10001
|
||||
|
||||
FROM runtime-base AS admin
|
||||
COPY --from=build-admin /out/telesrv-admin /usr/local/bin/telesrv-admin
|
||||
EXPOSE 2600
|
||||
CMD ["telesrv-admin"]
|
||||
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.1.0`), using a canonical Layer 228 schema with sparse `tlprofile`
|
||||
exact Layer 225-228 compatibility profiles.
|
||||
(`v1.3.2`), using a canonical Layer 229 schema with sparse `tlprofile`
|
||||
exact Layer 225-229 compatibility profiles.
|
||||
|
||||
If you are looking for a **Telegram server**, **MTProto server**,
|
||||
**Telegram backend**, **Telegram clone server**, or **self-hosted
|
||||
|
|
@ -21,7 +21,10 @@ 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 layer 228**.
|
||||
> 🔗 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.
|
||||
|
||||
`OwpenGram Server` is independent and unofficial. It is not affiliated with, endorsed by,
|
||||
or sponsored by Telegram or the official Telegram team.
|
||||
|
|
@ -36,6 +39,9 @@ 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
|
||||
|
|
@ -50,6 +56,15 @@ 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>
|
||||
|
|
@ -57,7 +72,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 228, and sparse exact Layer 225-228 compatibility profiles. |
|
||||
| ✅ | 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. |
|
||||
| ✅ | 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. |
|
||||
|
|
@ -72,21 +87,24 @@ 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, 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. |
|
||||
| ✅ | 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. |
|
||||
| ✅ | 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
|
||||
|
|
@ -94,13 +112,56 @@ git clone https://github.com/owpengram/owpengram-server.git
|
|||
cd owpengram-server
|
||||
```
|
||||
|
||||
**2. Start the infrastructure** (PostgreSQL + Redis)
|
||||
**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)
|
||||
|
||||
```powershell
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
**3. Build and run the server**
|
||||
**Build and run the server**
|
||||
|
||||
Windows (PowerShell):
|
||||
|
||||
|
|
@ -123,42 +184,86 @@ workers in the same process.
|
|||
|
||||
> **Default local login code:** `12345` — change it before any real use!
|
||||
|
||||
> 💡 **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.
|
||||
Without the web wizard you also have to fill in `.env` yourself; see
|
||||
"⚙️ Configuration" below and [`.env.example`](.env.example).
|
||||
|
||||
### 🖥️ Server Panel (optional)
|
||||
</details>
|
||||
|
||||
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.
|
||||
### 🖥️ Server Panel
|
||||
|
||||
```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.
|
||||
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.
|
||||
|
||||
What it does:
|
||||
|
||||
- 🧙 **First-run setup wizard** — walks through the required `.env` values
|
||||
before the first start.
|
||||
- 🧙 **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.
|
||||
- ▶️ **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.
|
||||
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.
|
||||
- 📜 **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
|
||||
|
|
@ -256,6 +361,17 @@ 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
|
||||
|
|
@ -317,37 +433,47 @@ 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:
|
||||
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:
|
||||
|
||||
- 🤖 [Android client](https://github.com/owpengram/owpengram-android-client)
|
||||
- 💻 [Desktop client](https://github.com/owpengram/owpengram-desktop-client)
|
||||
| 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 |
|
||||
|
||||
A stock Telegram client will not connect, since it only trusts Telegram's own
|
||||
DC list and RSA keys.
|
||||
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`.
|
||||
|
||||
- Telegram Desktop commit: `9caf32dffc90ddd9bb08ad5777b865f729fa167b`
|
||||
- Canonical TL layer: 228
|
||||
- Exact compatibility profiles: Layer 225-228
|
||||
- Local DC: `127.0.0.1:2398`, DC id `2`
|
||||
**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.
|
||||
|
||||
**1. Export your server's public key**
|
||||
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:
|
||||
|
||||
After the server generates `data/server_rsa.pem`, export the matching public
|
||||
key as PEM:
|
||||
|
||||
```powershell
|
||||
```bash
|
||||
openssl rsa -in data/server_rsa.pem -RSAPublicKey_out -out data/server_rsa.pub
|
||||
```
|
||||
|
||||
**2. Add the server in the client**
|
||||
or just read the JSON the client reads:
|
||||
|
||||
On the login screen, open server selection → **Add Server**, and fill in:
|
||||
```bash
|
||||
curl http://your-server:2398/owpengram/server-info
|
||||
```
|
||||
|
||||
- **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
|
||||
> 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.
|
||||
|
||||
## 🧪 Development: multi-device smoke test
|
||||
|
||||
|
|
@ -374,16 +500,21 @@ 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 web UI
|
||||
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
|
||||
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
|
||||
internal/mtprotoedge/ MTProto transport, auth key, session, ack/resend, server-info endpoints
|
||||
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
|
||||
|
|
|
|||
47
build.sh
Executable file
47
build.sh
Executable file
|
|
@ -0,0 +1,47 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build the owpengram-server container image (stamping the current git state into
|
||||
# the binary - .containerignore excludes .git, so go build can't see the repo and
|
||||
# the values are passed in here), then restart the owpengram-server and
|
||||
# owpengram-admin systemd services so they pick up the freshly built image.
|
||||
# Container creation/lifecycle beyond the pod itself is owned by those systemd
|
||||
# units, not this script.
|
||||
#
|
||||
# Usage: ./build.sh [extra podman build args...]
|
||||
# IMAGE=my/tag ./build.sh override the image tag (default: owpengram-server)
|
||||
# POD=name ./build.sh override the pod name (default: owpengram)
|
||||
# NO_DEPLOY=1 ./build.sh build the image only, don't restart the services
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
IMAGE="${IMAGE:-owpengram-server}"
|
||||
POD="${POD:-owpengram}"
|
||||
|
||||
podman build \
|
||||
--build-arg GIT_COMMIT="$(git rev-parse HEAD)" \
|
||||
--build-arg GIT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" \
|
||||
--build-arg GIT_TREE_STATE="$(git diff --quiet && echo clean || echo dirty)" \
|
||||
--build-arg BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
-t "$IMAGE" \
|
||||
-f Containerfile \
|
||||
"$@" \
|
||||
.
|
||||
|
||||
if [ "${NO_DEPLOY:-0}" = "1" ]; then
|
||||
echo "built $IMAGE (NO_DEPLOY=1, services unchanged)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! podman pod exists "$POD"; then
|
||||
echo "pod '$POD' does not exist - creating it"
|
||||
podman pod create --name "$POD" \
|
||||
-p 2398:2398 \
|
||||
-p 127.0.0.1:2600:2600 \
|
||||
-p 2400:2400 \
|
||||
-p 2500:2500 \
|
||||
-p 12399:12399/udp \
|
||||
-p 12400:12400/udp \
|
||||
-p 12500-12999:12500-12999/udp
|
||||
fi
|
||||
|
||||
systemctl restart owpengram-server.service owpengram-admin.service
|
||||
systemctl --no-pager status owpengram-server.service owpengram-admin.service
|
||||
181
cmd/createuser/main.go
Normal file
181
cmd/createuser/main.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// Command createuser inserts a users row with an operator-chosen id, bypassing
|
||||
// the normal users_id_seq auto-assignment. This works because users.id is
|
||||
// GENERATED BY DEFAULT AS IDENTITY (not GENERATED ALWAYS) -- an explicit id in
|
||||
// the INSERT is honored, the same mechanism ensureOfficialSystemUserWithDB
|
||||
// (internal/store/postgres/message_send.go) already relies on to seed the
|
||||
// built-in system accounts (ChatBot, BotFather, ...) at their fixed ids.
|
||||
//
|
||||
// Normal signup (auth.signUp) never lets a caller pick an id, so this exists
|
||||
// purely for local/dev tooling -- reserving a specific low id (below
|
||||
// OfficialSystemUserID=777000, say) for a test account.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// createuser -id 1000 [-first-name Test] [-last-name User] [-username testuser] -phone "15550001234"
|
||||
// createuser -id 1000 [-first-name Test] [-last-name User] [-username testuser] -email "test@example.com"
|
||||
//
|
||||
// -phone and -email are mutually exclusive: an email-signup account never
|
||||
// stores the address in users.phone directly (see internal/domain/emailphone.go)
|
||||
// -- it gets a synthetic "888"-prefixed display phone instead (the same one
|
||||
// assignEmailSignupDisplayPhone hands a real email-signup account), with the
|
||||
// real address recorded separately in signup_email.
|
||||
//
|
||||
// Reads TELESRV_POSTGRES_DSN the same way the server does (internal/config).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/config"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// maxEmailSignupPhoneAttempts bounds the display-phone collision-retry loop,
|
||||
// mirroring internal/app/auth/service.go's own constant of the same name.
|
||||
const maxEmailSignupPhoneAttempts = 20
|
||||
|
||||
func randomInt64() (int64, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return 0, fmt.Errorf("rand: %w", err)
|
||||
}
|
||||
return int64(binary.LittleEndian.Uint64(b[:])), nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
id := flag.Int64("id", 0, "user id to create (required)")
|
||||
firstName := flag.String("first-name", "Test", "first name")
|
||||
lastName := flag.String("last-name", "", "last name")
|
||||
username := flag.String("username", "", "username, without @ (optional)")
|
||||
phone := flag.String("phone", "", "phone number (optional; mutually exclusive with -email)")
|
||||
email := flag.String("email", "", "email address for an email-signup account (optional; mutually exclusive with -phone)")
|
||||
force := flag.Bool("force", false, "skip the reserved-id / sequence-collision safety checks")
|
||||
flag.Parse()
|
||||
|
||||
if *id <= 0 {
|
||||
fmt.Fprintln(os.Stderr, "createuser: -id is required and must be positive")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *phone != "" && *email != "" {
|
||||
fmt.Fprintln(os.Stderr, "createuser: -phone and -email are mutually exclusive")
|
||||
os.Exit(2)
|
||||
}
|
||||
if !*force {
|
||||
if domain.IsSystemUserID(*id) {
|
||||
fmt.Fprintf(os.Stderr, "createuser: %d is a reserved built-in system account id (see internal/domain/system.go) - refusing, pass -force to override\n", *id)
|
||||
os.Exit(2)
|
||||
}
|
||||
if *id >= domain.UserIDSequenceBase {
|
||||
fmt.Fprintf(os.Stderr, "createuser: %d is >= UserIDSequenceBase (%d) - a future organic signup could eventually collide with it; pass -force to proceed anyway (then consider bumping users_id_seq yourself)\n", *id, domain.UserIDSequenceBase)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "createuser: load config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pool, err := pgxpool.New(ctx, cfg.PostgresDSN)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "createuser: connect: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
accessHash, err := randomInt64()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "createuser: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
displayPhone := *phone
|
||||
signupEmail := ""
|
||||
if *email != "" {
|
||||
signupEmail = domain.NormalizeEmailForPhone(*email)
|
||||
displayPhone, err = assignEmailSignupDisplayPhone(ctx, pool)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "createuser: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// phone/username/signup_email all sit under partial unique indexes that
|
||||
// exclude '', so leaving any of them blank never collides with another
|
||||
// blank-valued account.
|
||||
row := pool.QueryRow(ctx, `
|
||||
INSERT INTO users (id, access_hash, phone, signup_email, first_name, last_name, username, country_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, '')
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
RETURNING id`,
|
||||
*id, accessHash, displayPhone, signupEmail, *firstName, *lastName, *username)
|
||||
|
||||
var createdID int64
|
||||
if err := row.Scan(&createdID); err != nil {
|
||||
fmt.Fprintln(os.Stderr, describeInsertFailure(*id, *username, displayPhone, signupEmail, err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("created user id=%d access_hash=%d first_name=%q last_name=%q username=%q phone=%q signup_email=%q\n",
|
||||
createdID, accessHash, *firstName, *lastName, *username, displayPhone, signupEmail)
|
||||
}
|
||||
|
||||
// describeInsertFailure turns the INSERT's failure into a message naming the
|
||||
// actual thing that collided, instead of "id already exists" for every case:
|
||||
// ON CONFLICT (id) DO NOTHING only covers the id itself, so a duplicate
|
||||
// username/phone/signup_email surfaces here as a distinct unique-violation
|
||||
// error (pgx.ErrNoRows only means the id itself was the conflict).
|
||||
func describeInsertFailure(id int64, username, phone, signupEmail string, err error) string {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
switch pgErr.ConstraintName {
|
||||
case "users_username_lower_unique_idx":
|
||||
return fmt.Sprintf("createuser: username %q is already taken", username)
|
||||
case "users_phone_unique_idx":
|
||||
return fmt.Sprintf("createuser: phone %q is already in use", phone)
|
||||
case "users_signup_email_lower_unique_idx":
|
||||
return fmt.Sprintf("createuser: email %q is already in use by another account", signupEmail)
|
||||
default:
|
||||
return fmt.Sprintf("createuser: unique constraint %q violated: %v", pgErr.ConstraintName, err)
|
||||
}
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Sprintf("createuser: id %d already exists", id)
|
||||
}
|
||||
return fmt.Sprintf("createuser: insert failed: %v", err)
|
||||
}
|
||||
|
||||
// assignEmailSignupDisplayPhone mirrors internal/app/auth/service.go's method
|
||||
// of the same name: pick a random "888"-prefixed display phone and re-roll on
|
||||
// the astronomically unlikely collision with an existing account's phone.
|
||||
func assignEmailSignupDisplayPhone(ctx context.Context, pool *pgxpool.Pool) (string, error) {
|
||||
for range maxEmailSignupPhoneAttempts {
|
||||
candidate, err := domain.NewEmailSignupDisplayPhone(domain.EmailPhonePrefix)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var exists bool
|
||||
if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM users WHERE phone = $1)`, candidate).Scan(&exists); err != nil {
|
||||
return "", fmt.Errorf("check display phone collision: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("assign email signup display phone: exhausted %d attempts", maxEmailSignupPhoneAttempts)
|
||||
}
|
||||
32
cmd/telesrv-admin/addserverlink.go
Normal file
32
cmd/telesrv-admin/addserverlink.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
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(),
|
||||
})
|
||||
}
|
||||
154
cmd/telesrv-admin/adminauth.go
Normal file
154
cmd/telesrv-admin/adminauth.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
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
|
||||
}
|
||||
292
cmd/telesrv-admin/adminauth_test.go
Normal file
292
cmd/telesrv-admin/adminauth_test.go
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
125
cmd/telesrv-admin/adminusers.go
Normal file
125
cmd/telesrv-admin/adminusers.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
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
|
||||
}
|
||||
400
cmd/telesrv-admin/adminusers_api.go
Normal file
400
cmd/telesrv-admin/adminusers_api.go
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
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
|
||||
}
|
||||
101
cmd/telesrv-admin/adminusers_guard_integration_test.go
Normal file
101
cmd/telesrv-admin/adminusers_guard_integration_test.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
45
cmd/telesrv-admin/buildinfo.go
Normal file
45
cmd/telesrv-admin/buildinfo.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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,9 +7,11 @@ import (
|
|||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
|
@ -27,6 +29,23 @@ 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)
|
||||
|
|
@ -47,7 +66,7 @@ func run() error {
|
|||
}
|
||||
defer pool.Close()
|
||||
|
||||
hs := hoststats.NewPoller(cfg.BlobDir)
|
||||
hs := hoststats.NewPoller(cfg.DiskStatsPath)
|
||||
go hs.Run(ctx, hostStatsPollInterval)
|
||||
|
||||
srv, err := newServer(cfg, newReadStore(pool), hs)
|
||||
|
|
@ -80,10 +99,10 @@ type uiConfig struct {
|
|||
Password string
|
||||
Token string
|
||||
SessionKey []byte
|
||||
// BlobDir is the local blob-storage root, reused only to pick which
|
||||
// filesystem the dashboard's disk-free reading statfs's -- irrelevant when
|
||||
// TELESRV_BLOB_BACKEND=s3, where disk space isn't the storage constraint.
|
||||
BlobDir string
|
||||
// DiskStatsPath points the dashboard host-disk sampler at the local path
|
||||
// that matters for the selected blob backend: permanent localfs storage or
|
||||
// the S3 upload spool.
|
||||
DiskStatsPath string
|
||||
// Permissions is the right set a panel session is issued with, from
|
||||
// TELESRV_ADMIN_UI_PERMISSIONS. The shipped default is the single wildcard
|
||||
// entry, so introducing the permission model never locks an operator out of a
|
||||
|
|
@ -95,6 +114,41 @@ 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 配置文件与环境变量,
|
||||
|
|
@ -121,6 +175,20 @@ 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,
|
||||
|
|
@ -129,12 +197,26 @@ func loadConfig() (uiConfig, error) {
|
|||
Password: appCfg.AdminUIPassword,
|
||||
Token: appCfg.AdminUIToken,
|
||||
SessionKey: sum[:],
|
||||
DiskStatsPath: dashboardDiskPath(appCfg),
|
||||
Permissions: appCfg.AdminUIPermissions,
|
||||
HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
|
||||
BlobDir: appCfg.BlobDir,
|
||||
IdentityDir: appCfg.IdentityDir,
|
||||
WelcomeMessagePhoneDefault: appCfg.WelcomeMessagePhoneTemplate,
|
||||
WelcomeMessageEmailDefault: appCfg.WelcomeMessageEmailTemplate,
|
||||
LoginCodeMessageDefault: appCfg.LoginCodeMessageTemplate,
|
||||
RepoRoot: repoRoot,
|
||||
AdvertiseHost: appCfg.AdvertiseIP,
|
||||
ServerPort: serverPort,
|
||||
}, 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 == "" {
|
||||
|
|
|
|||
64
cmd/telesrv-admin/publicbranding.go
Normal file
64
cmd/telesrv-admin/publicbranding.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
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,6 +12,7 @@ import (
|
|||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -386,6 +387,7 @@ 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
|
||||
|
|
@ -396,7 +398,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)
|
||||
LIMIT $7`, id, phonePrefix, substring, term, beforeActiveUS, beforeID, limit+1, domain.SystemUserIDs())
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("search accounts: %w", err)
|
||||
}
|
||||
|
|
@ -508,44 +510,77 @@ 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
|
||||
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, `
|
||||
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, `
|
||||
SELECT count(*) FROM users WHERE is_bot AND deleted_at IS NULL`).Scan(&out.Bots); err != nil {
|
||||
return out, fmt.Errorf("count bots: %w", err)
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
return fmt.Errorf("count bots: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
if err := s.pool.QueryRow(gctx, `
|
||||
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 out, fmt.Errorf("count channels: %w", err)
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
return fmt.Errorf("count channels: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
if err := s.pool.QueryRow(gctx, `
|
||||
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 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, `
|
||||
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, `
|
||||
SELECT count(DISTINCT document_id) FROM user_sticker_collections WHERE kind = 'gif'`).Scan(&out.Gifs); err != nil {
|
||||
return out, fmt.Errorf("count gifs: %w", err)
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
return fmt.Errorf("count gifs: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
if err := s.pool.QueryRow(gctx, `
|
||||
SELECT count(*) FROM moderation_cases WHERE status NOT IN ('resolved', 'dismissed')`).Scan(&out.PendingReports); err != nil {
|
||||
return out, fmt.Errorf("count pending moderation cases: %w", err)
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
return fmt.Errorf("count pending moderation cases: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
if err := s.pool.QueryRow(gctx, `
|
||||
SELECT count(*) FROM verification_applications WHERE status IN ('submitted', 'in_review')`).Scan(&out.PendingVerifications); err != nil {
|
||||
return out, fmt.Errorf("count pending verification applications: %w", err)
|
||||
return fmt.Errorf("count pending verification applications: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return DashboardCounts{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -837,19 +872,23 @@ WITH auth AS (
|
|||
SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at,
|
||||
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified, u.scam, u.fake,
|
||||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
|
||||
auth.last_active_at, auth.device_count,
|
||||
COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), COALESCE(auth.device_count, 0)::int,
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username,
|
||||
COALESCE(ap.login_email, ''),
|
||||
`+accountCollectibleUsernamesColumn+` AS collectibles
|
||||
FROM users u
|
||||
JOIN auth ON auth.user_id = u.id
|
||||
-- LEFT JOIN, not JOIN: an account with no authorizations (never finished login,
|
||||
-- all sessions revoked, frozen-then-unfrozen) must still appear here, matching
|
||||
-- CountAccounts and SearchAccounts.
|
||||
LEFT JOIN auth ON auth.user_id = u.id
|
||||
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 ($1::bigint = 0 OR (auth.last_active_at, u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
|
||||
ORDER BY auth.last_active_at DESC, u.id DESC
|
||||
LIMIT $3`, beforeActiveUS, beforeID, limit+1)
|
||||
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())
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list accounts: %w", err)
|
||||
}
|
||||
|
|
@ -954,27 +993,31 @@ 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, with sent/failed counts
|
||||
// derived live from broadcast_recipients (never stored, so they can't drift).
|
||||
// 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.
|
||||
type BroadcastRow struct {
|
||||
ID int64
|
||||
Message string
|
||||
TargetMode string
|
||||
TotalCount int
|
||||
SentCount int
|
||||
FailedCount int
|
||||
CreatedBy string
|
||||
CreatedAt time.Time
|
||||
ID int64
|
||||
Message string
|
||||
TargetMode string
|
||||
TargetCount int64
|
||||
MaterializedCount int64
|
||||
SentCount int64
|
||||
FailedCount int64
|
||||
EnumerationDone bool
|
||||
CreatedBy string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const broadcastRowColumns = `
|
||||
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`
|
||||
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`
|
||||
|
||||
func scanBroadcastRow(row interface{ Scan(...any) error }, item *BroadcastRow) error {
|
||||
return row.Scan(&item.ID, &item.Message, &item.TargetMode, &item.TotalCount, &item.CreatedBy, &item.CreatedAt,
|
||||
&item.SentCount, &item.FailedCount)
|
||||
return row.Scan(&item.ID, &item.Message, &item.TargetMode, &item.TargetCount, &item.MaterializedCount,
|
||||
&item.SentCount, &item.FailedCount, &item.EnumerationDone, &item.CreatedBy, &item.CreatedAt)
|
||||
}
|
||||
|
||||
// ListBroadcasts pages campaigns newest-first.
|
||||
|
|
@ -985,9 +1028,7 @@ 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 {
|
||||
|
|
@ -2535,62 +2576,181 @@ const (
|
|||
)
|
||||
|
||||
// perOwnerMediaSizeSQL is shared between StorageStats and
|
||||
// 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.
|
||||
// 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.
|
||||
const perOwnerMediaSizeSQL = `
|
||||
SELECT owner_user_id, size FROM documents
|
||||
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
|
||||
UNION ALL
|
||||
SELECT p.owner_user_id, COALESCE((
|
||||
SELECT MAX((elem->>'size')::bigint) FROM jsonb_array_elements(p.sizes) elem
|
||||
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 || ':%'
|
||||
), 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) 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).
|
||||
// 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.
|
||||
type StorageStatsRow struct {
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
stats.BackendKind = strings.ToLower(strings.TrimSpace(os.Getenv("TELESRV_BLOB_BACKEND")))
|
||||
if stats.BackendKind == "" {
|
||||
stats.BackendKind = "s3"
|
||||
|
|
@ -2608,11 +2768,25 @@ type AccountStorageRow struct {
|
|||
FileCount int64 `json:"FileCount,string"`
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
if limit <= 0 {
|
||||
limit = storageUsageListDefaultLimit
|
||||
}
|
||||
|
|
@ -2622,19 +2796,51 @@ func (s *readStore) ListAccountStorageUsage(ctx context.Context, offset, limit i
|
|||
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
|
||||
WHERE owner_user_id <> 0 AND size > 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
|
||||
ORDER BY t.bytes DESC, t.owner_user_id
|
||||
OFFSET $1
|
||||
LIMIT $2`, offset, limit+1)
|
||||
`+whereClause+`
|
||||
ORDER BY `+column+` `+direction+`, t.owner_user_id
|
||||
OFFSET $`+strconv.Itoa(offsetArg)+`
|
||||
LIMIT $`+strconv.Itoa(limitArg), args...)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list account storage usage: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ VALUES ($1, $2, $3, 'Collector', '', $4, now(), now())`,
|
|||
userID, userID, "+1889"+suffix, editable); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
// The list query joins authorizations, so an account with no device never
|
||||
// appears there at all; an authorization in turn needs its auth key to exist.
|
||||
// Give this account a device so its device_count / last_active columns are
|
||||
// exercised; an authorization needs its auth key to exist first.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, '\x00', 0)`, userID); err != nil {
|
||||
t.Fatalf("seed auth key: %v", err)
|
||||
|
|
@ -123,6 +123,44 @@ WHERE peer_type='user' AND peer_id=$1 AND collectible_id IS NOT NULL`, userID);
|
|||
}
|
||||
}
|
||||
|
||||
// An account with no authorizations (never finished login, all sessions revoked,
|
||||
// frozen-then-unfrozen) must still show up in the Accounts tab - it did not,
|
||||
// because ListAccounts inner-joined the authorizations aggregate.
|
||||
func TestReadStoreListAccountsIncludesAccountsWithoutSessions(t *testing.T) {
|
||||
store, pool := verificationReadStore(t)
|
||||
ctx := context.Background()
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano()%1_000_000)
|
||||
userID := 3_700_000_000 + time.Now().UnixNano()%1_000_000
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, userID)
|
||||
})
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO users (id, access_hash, phone, first_name, last_name, username, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, 'Sessionless', '', '', now(), now())`,
|
||||
userID, userID, "+42777"+suffix); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
// Deliberately no auth_keys / authorizations rows.
|
||||
|
||||
rows, _, err := store.ListAccounts(ctx, 0, 0, 500)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
}
|
||||
found := false
|
||||
for i := range rows {
|
||||
if rows[i].ID == userID {
|
||||
found = true
|
||||
if rows[i].DeviceCount != 0 {
|
||||
t.Fatalf("device count = %d, want 0 for a sessionless account", rows[i].DeviceCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("sessionless account %d absent from ListAccounts (%d rows)", userID, len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func assertCollectibles(t *testing.T, surface string, row AccountRow, editable string, want []AccountUsername) {
|
||||
t.Helper()
|
||||
if row.Username != editable {
|
||||
|
|
|
|||
134
cmd/telesrv-admin/routescope_test.go
Normal file
134
cmd/telesrv-admin/routescope_test.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
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,6 +36,8 @@ import (
|
|||
// TELESRV_ADMIN_UI_PERMISSIONS and the ones the admin API enforces.
|
||||
const (
|
||||
permissionAll = "*"
|
||||
permissionPremiumManage = "premium.manage"
|
||||
permissionBotTokenRead = "bots.token.read"
|
||||
permissionVerificationReview = "verification.review"
|
||||
permissionVerificationRevoke = "verification.revoke"
|
||||
// Third-party bot verification. Deliberately not implied by the official
|
||||
|
|
@ -45,8 +47,122 @@ 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,
|
||||
|
|
@ -67,12 +183,46 @@ 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{}, newPanelPermissions(claims.Permissions))
|
||||
ctx = context.WithValue(ctx, permissionsKey{}, 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
|
||||
|
|
|
|||
|
|
@ -12,14 +12,21 @@ import (
|
|||
"io/fs"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"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
|
||||
|
|
@ -31,6 +38,8 @@ 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) {
|
||||
|
|
@ -44,6 +53,8 @@ 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
|
||||
}
|
||||
|
||||
|
|
@ -53,85 +64,106 @@ 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.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/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/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)))
|
||||
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)))
|
||||
// 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))
|
||||
|
|
@ -140,9 +172,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.requireAuthAPI(
|
||||
s.requirePermission(permissionVerificationReview,
|
||||
s.requirePermission(permissionVerificationRevoke, http.HandlerFunc(s.handleRevokeVerificationAPI)))))
|
||||
mux.Handle("POST /api/actions/revoke-verification", s.scopedRouteAll(
|
||||
[]string{permissionVerificationReview, 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,
|
||||
|
|
@ -163,6 +195,26 @@ 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")
|
||||
})
|
||||
|
|
@ -176,7 +228,11 @@ func actorFromContext(ctx context.Context) string {
|
|||
if actor, ok := ctx.Value(actorKey{}).(string); ok && actor != "" {
|
||||
return actor
|
||||
}
|
||||
return "admin"
|
||||
// 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"
|
||||
}
|
||||
|
||||
func (s *server) handleApp(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -193,7 +249,12 @@ func (s *server) handleApp(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Secret string `json:"secret"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// sessionTTL bounds a signed panel session and the CSRF cookie that goes with it,
|
||||
|
|
@ -213,7 +274,11 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
|
|||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !s.validSecret(req.Secret) {
|
||||
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.
|
||||
writeAPIError(w, http.StatusUnauthorized, "invalid credential")
|
||||
return
|
||||
}
|
||||
|
|
@ -222,9 +287,11 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
|
|||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
permissions := newPanelPermissions(s.cfg.Permissions)
|
||||
permissions := newPanelPermissions(identity.permissions)
|
||||
value, err := signSession(s.cfg.SessionKey, sessionClaims{
|
||||
Actor: "admin",
|
||||
Actor: identity.actor,
|
||||
UserID: identity.userID,
|
||||
Epoch: identity.epoch,
|
||||
Exp: time.Now().Add(sessionTTL).Unix(),
|
||||
Nonce: newCommandID("sess"),
|
||||
Permissions: permissions.List(),
|
||||
|
|
@ -244,7 +311,7 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
setCSRFCookie(w, csrfToken, sessionTTL)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"actor": "admin",
|
||||
"actor": identity.actor,
|
||||
"permissions": permissions.List(),
|
||||
"csrf_token": csrfToken,
|
||||
"hide_third_party_verification": s.cfg.HideThirdPartyVerification,
|
||||
|
|
@ -253,6 +320,16 @@ 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 {
|
||||
|
|
@ -266,14 +343,56 @@ 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -285,13 +404,26 @@ func (s *server) handleDashboardAPI(w http.ResponseWriter, r *http.Request) {
|
|||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
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 {
|
||||
// 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 {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
|
@ -898,10 +1030,13 @@ type createBroadcastAPIRequest struct {
|
|||
UserIDs []int64 `json:"user_ids,omitempty"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func (s *server) handleCreateBroadcastAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body createBroadcastAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
|
|
@ -909,16 +1044,7 @@ func (s *server) handleCreateBroadcastAPI(w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
userIDs := body.UserIDs
|
||||
if body.TargetMode == "all" {
|
||||
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
|
||||
userIDs = nil
|
||||
}
|
||||
req := admin.CreateBroadcastRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "create-broadcast"),
|
||||
|
|
@ -1406,6 +1532,52 @@ 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"`
|
||||
|
|
@ -1980,6 +2152,37 @@ 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"`
|
||||
|
|
@ -2239,6 +2442,69 @@ type mintCollectibleUsernameAPIRequest struct {
|
|||
PurchaseDate flexUnix `json:"purchase_date"`
|
||||
}
|
||||
|
||||
type reserveUsernameAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
func (s *server) handleReserveUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body reserveUsernameAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.ReserveUsernameRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "reserve-username"),
|
||||
Username: body.Username,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/reserved-usernames/reserve", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
func (s *server) handleUnreserveUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body reserveUsernameAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.UnreserveUsernameRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "unreserve-username"),
|
||||
Username: body.Username,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/reserved-usernames/unreserve", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
func (s *server) handleReservedUsernamesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
params := url.Values{}
|
||||
for _, name := range []string{"q", "limit", "offset"} {
|
||||
if v := strings.TrimSpace(q.Get(name)); v != "" {
|
||||
params.Set(name, v)
|
||||
}
|
||||
}
|
||||
apiPath := "/v1/reserved-usernames"
|
||||
if enc := params.Encode(); enc != "" {
|
||||
apiPath += "?" + enc
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "request build failed")
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadGateway, "admin api unreachable")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (s *server) handleMintCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body mintCollectibleUsernameAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
|
|
@ -2438,7 +2704,14 @@ func (s *server) handleStorageAccountsAPI(w http.ResponseWriter, r *http.Request
|
|||
writeAPIError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
rows, hasMore, err := s.read.ListAccountStorageUsage(r.Context(), offset, limit)
|
||||
sortDesc := query.Get("order") != "asc"
|
||||
rows, hasMore, err := s.read.ListAccountStorageUsage(
|
||||
r.Context(),
|
||||
query.Get("q"),
|
||||
query.Get("sort"),
|
||||
sortDesc,
|
||||
offset,
|
||||
limit)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
|
|
|
|||
425
cmd/telesrv-admin/serversettings.go
Normal file
425
cmd/telesrv-admin/serversettings.go
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
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}))
|
||||
}
|
||||
87
cmd/telesrv-admin/serversettings_test.go
Normal file
87
cmd/telesrv-admin/serversettings_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
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,10 +26,28 @@ type sessionClaims struct {
|
|||
Actor string `json:"actor"`
|
||||
Exp int64 `json:"exp"`
|
||||
Nonce string `json:"nonce"`
|
||||
// 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.
|
||||
// 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 []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,6 +144,53 @@ func TestModerationReadAPIDisablesBrowserCaching(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationRowJSONPreservesInt64AsDecimalStrings(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
raw, err := json.Marshal(AuthorizationRow{AuthKeyID: maxInt64, Hash: maxInt64})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal authorization row: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal authorization row: %v", err)
|
||||
}
|
||||
for _, field := range []string{"AuthKeyID", "Hash"} {
|
||||
if got[field] != "9223372036854775807" {
|
||||
t.Fatalf("authorization %s = %#v, want exact decimal string", field, got[field])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeSessionsBFFForwardsExactAuthorizationHash(t *testing.T) {
|
||||
const authorizationHash = int64(2361577175213625973)
|
||||
var got admin.RevokeSessionsRequest
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/accounts/revoke-sessions" || r.Header.Get("Authorization") != "Bearer secret" {
|
||||
t.Fatalf("upstream request path=%q authorization=%q", r.URL.Path, r.Header.Get("Authorization"))
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed", DryRun: got.DryRun})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/revoke-sessions", strings.NewReader(`{
|
||||
"reason":"precision regression","confirm":false,"user_id":1001,
|
||||
"hash":"2361577175213625973"
|
||||
}`))
|
||||
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleRevokeSessionsAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.Hash != authorizationHash || got.Actor != "operator" || !got.DryRun {
|
||||
t.Fatalf("forwarded revoke request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintCollectibleUsernameBFFForwardsActorAndTolerantScalars(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
var got admin.MintCollectibleUsernameRequest
|
||||
|
|
|
|||
|
|
@ -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(`{"secret":"letmein"}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"username":"owpengram","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 != "admin" || len(body.Permissions) != 1 || body.Permissions[0] != permissionVerificationReview {
|
||||
if body.Actor != breakGlassUsername || 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(`{"secret":"letmein"}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"username":"owpengram","secret":"letmein"}`))
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-DTpNyCcP.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-DTpNyCcP.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cmd/telesrv-admin/web/dist/assets/index-P_k7ini0.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-P_k7ini0.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-fn4QJaPB.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-fn4QJaPB.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-Bt9UBcEE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
|
||||
<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>
|
||||
|
|
|
|||
6
cmd/telesrv-admin/web/package-lock.json
generated
6
cmd/telesrv-admin/web/package-lock.json
generated
|
|
@ -758,9 +758,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
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 { PermissionsProvider } from "./permissions";
|
||||
import { permissionAll, permissionServerManage, PermissionsProvider } from "./permissions";
|
||||
import { Routes } from "./pages/Routes";
|
||||
import { currentRoute, type RouteState } from "./routing";
|
||||
import type { AdminSession } from "./types";
|
||||
|
|
@ -37,12 +38,39 @@ export function App() {
|
|||
}
|
||||
|
||||
if (session === null) {
|
||||
return <LoginPage onLogin={setSession} />;
|
||||
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 (
|
||||
<PermissionsProvider permissions={session.permissions ?? []} hideThirdPartyVerification={session.hide_third_party_verification ?? true}>
|
||||
<Shell actor={session.actor} route={route} navigate={navigate} onLogout={() => setSession(null)}>
|
||||
<Shell actor={session.actor} apiLayers={session.api_layers} build={session.build} route={route} navigate={navigate} onLogout={() => setSession(null)}>
|
||||
<Routes route={route} navigate={navigate} />
|
||||
</Shell>
|
||||
</PermissionsProvider>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type {
|
||||
PublicBranding,
|
||||
AdminConsoleUserList,
|
||||
AccountDetail,
|
||||
AccountListResponse,
|
||||
AccountStatsResponse,
|
||||
|
|
@ -22,7 +24,12 @@ import type {
|
|||
ChannelListResponse,
|
||||
CollectibleUsernameDetail,
|
||||
CollectibleUsernameListResponse,
|
||||
ReservedUsernameListResponse,
|
||||
CommandResult,
|
||||
DockerService,
|
||||
EnvGroup,
|
||||
ServerIdentity,
|
||||
ServerStatus,
|
||||
GroupMessageDetail,
|
||||
GroupMessageListResponse,
|
||||
MessageDetail,
|
||||
|
|
@ -140,16 +147,24 @@ export function errorMessage(error: unknown): string {
|
|||
|
||||
export const api = {
|
||||
session: () => request<AdminSession>("/api/session"),
|
||||
login: async (secret: string) => {
|
||||
// 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 = "") => {
|
||||
const result = await request<AdminLoginResult>("/api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ secret })
|
||||
body: JSON.stringify({ username, 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()}`),
|
||||
|
|
@ -163,6 +178,8 @@ export const api = {
|
|||
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
|
||||
collectibleUsername: (id: string) =>
|
||||
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
|
||||
reservedUsernames: (params: URLSearchParams) =>
|
||||
request<ReservedUsernameListResponse>(`/api/reserved-usernames?${params.toString()}`),
|
||||
dashboard: () => request<DashboardResponse>("/api/dashboard"),
|
||||
storageStats: () => request<StorageStatsResponse>("/api/storage/stats"),
|
||||
storageAccounts: (params: URLSearchParams) =>
|
||||
|
|
@ -228,10 +245,19 @@ 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)
|
||||
|
|
|
|||
28
cmd/telesrv-admin/web/src/clipboard.ts
Normal file
28
cmd/telesrv-admin/web/src/clipboard.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// 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,10 +3,11 @@ 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";
|
||||
type ActionTone = "neutral" | "warn" | "danger" | "primary";
|
||||
|
||||
export function ActionButton({
|
||||
label,
|
||||
|
|
@ -77,7 +78,7 @@ export function ActionButton({
|
|||
}
|
||||
|
||||
const canConfirm = result?.dry_run && !result.error;
|
||||
const triggerClass = `btn ${tone === "danger" ? "danger" : tone === "warn" ? "warn" : ""} ${compact ? "compact-btn" : ""}`;
|
||||
const triggerClass = `btn ${tone === "danger" ? "danger" : tone === "warn" ? "warn" : tone === "primary" ? "primary" : ""} ${compact ? "compact-btn" : ""}`;
|
||||
const previewPayload = useMemo(() => {
|
||||
try {
|
||||
return payload();
|
||||
|
|
@ -94,7 +95,7 @@ export function ActionButton({
|
|||
: result?.details;
|
||||
|
||||
async function copySecret() {
|
||||
await navigator.clipboard.writeText(secretValue);
|
||||
await copyToClipboard(secretValue);
|
||||
setSecretCopied(true);
|
||||
}
|
||||
|
||||
|
|
|
|||
81
cmd/telesrv-admin/web/src/components/AddServerLinkModal.tsx
Normal file
81
cmd/telesrv-admin/web/src/components/AddServerLinkModal.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
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
|
||||
);
|
||||
}
|
||||
46
cmd/telesrv-admin/web/src/components/AppBackground.tsx
Normal file
46
cmd/telesrv-admin/web/src/components/AppBackground.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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,17 +5,20 @@ 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,10 +13,13 @@ 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("");
|
||||
|
|
@ -29,7 +32,7 @@ export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKi
|
|||
|
||||
async function submit() {
|
||||
if (!file) {
|
||||
setError("Choose an image file first.");
|
||||
setError("Choose an image or video file first.");
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
|
|
@ -41,9 +44,13 @@ export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKi
|
|||
try {
|
||||
const idField = kind === "channel" ? "channel_id" : "user_id";
|
||||
const form = new FormData();
|
||||
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, [idField]: id }));
|
||||
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("file", file, file.name);
|
||||
const result = kind === "channel" ? await api.setChannelAvatar(form) : await api.setAccountAvatar(form);
|
||||
const result = kind === "channel" ? await api.setChannelAvatar(form) : isVideo ? await api.setAccountAvatarVideo(form) : await api.setAccountAvatar(form);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
|
|
@ -71,11 +78,32 @@ 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="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>
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
164
cmd/telesrv-admin/web/src/components/BgIcons.tsx
Normal file
164
cmd/telesrv-admin/web/src/components/BgIcons.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
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,6 +2,7 @@ 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
|
||||
|
|
@ -34,7 +35,7 @@ export function CopyBotTokenModal({ botID, onClose }: { botID: number; onClose:
|
|||
setError(result.error || "No token returned.");
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(token);
|
||||
await copyToClipboard(token);
|
||||
setCopied(true);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ 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 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.
|
||||
// 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.
|
||||
export function CreateBroadcastModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [targetMode, setTargetMode] = useState<TargetMode>("all");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
AtSign,
|
||||
BadgeCheck,
|
||||
Ban,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Database,
|
||||
|
|
@ -9,20 +10,62 @@ 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 } from "../api";
|
||||
import { permissionBotVerificationReview, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
|
||||
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 { 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">
|
||||
|
|
@ -40,12 +83,16 @@ 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;
|
||||
|
|
@ -54,97 +101,282 @@ 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">
|
||||
<div className={`shell ${navCollapsed ? "shell--nav-collapsed" : ""}`.trim()}>
|
||||
<aside className="sidebar">
|
||||
<AppLink className="brand" href="/" navigate={navigate}>
|
||||
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
|
||||
<span className="brand-mark"><img src={brandIconSrc} alt={brandName} onError={() => setBrandIconFailed(true)} /></span>
|
||||
<span>
|
||||
<strong>OwpenGram</strong>
|
||||
<strong>{brandName}</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>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT 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="nav-section-toggle"
|
||||
type="button"
|
||||
aria-expanded={messagesOpen}
|
||||
onClick={() => setMessagesOpen((open) => !open)}
|
||||
{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")}
|
||||
>
|
||||
<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>
|
||||
{"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">
|
||||
<button
|
||||
className="btn ghost sidebar-server-action"
|
||||
type="button"
|
||||
title={"Connect this browser's client to this server"}
|
||||
disabled={connecting}
|
||||
onClick={() => void connectThisServer()}
|
||||
>
|
||||
<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"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{connectError && <div className="sidebar-server-action-error">{connectError}</div>}
|
||||
{addServerLinkOpen && <AddServerLinkModal onClose={() => setAddServerLinkOpen(false)} />}
|
||||
</aside>
|
||||
<div className="workspace">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<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>
|
||||
<h1>{routeTitle(route.path)}</h1>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<ThemeSwitch />
|
||||
<span className="actor-pill">{`Actor: ${actor}`}</span>
|
||||
<span className="actor-pill"><UserRound size={14} /> {actor}</span>
|
||||
<button className="btn ghost icon-text" type="button" onClick={logout} title={"Log out"}>
|
||||
<LogOut size={16} /> {"Log out"}
|
||||
</button>
|
||||
|
|
@ -152,6 +384,9 @@ 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -173,9 +408,16 @@ 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}>
|
||||
<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}
|
||||
>
|
||||
{icon ?? <span aria-hidden="true" className="nav-dot" />}
|
||||
<span>{children}</span>
|
||||
<span className="nav-item-label">{children}</span>
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
163
cmd/telesrv-admin/web/src/components/MessageView.tsx
Normal file
163
cmd/telesrv-admin/web/src/components/MessageView.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
513
cmd/telesrv-admin/web/src/components/SetupWizard.tsx
Normal file
513
cmd/telesrv-admin/web/src/components/SetupWizard.tsx
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
49
cmd/telesrv-admin/web/src/components/StatusScreen.tsx
Normal file
49
cmd/telesrv-admin/web/src/components/StatusScreen.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
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,11 +72,25 @@ export function StatusItem({ label, value, tone }: { label: string; value: strin
|
|||
);
|
||||
}
|
||||
|
||||
export function Metric({ label, value, tone = "neutral", mono = false }: { label: string; value: string; tone?: Tone; mono?: boolean }) {
|
||||
export function Metric({
|
||||
label,
|
||||
value,
|
||||
tone = "neutral",
|
||||
mono = false,
|
||||
loading = false
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: Tone;
|
||||
mono?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`metric ${tone}`}>
|
||||
<span>{label}</span>
|
||||
<strong className={mono ? "mono" : ""}>{value}</strong>
|
||||
<strong className={mono ? "mono" : ""} aria-busy={loading || undefined}>
|
||||
{loading ? <span className="skeleton skeleton-text" aria-label="Loading" /> : value}
|
||||
</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -119,6 +133,26 @@ 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>;
|
||||
}
|
||||
|
|
|
|||
35
cmd/telesrv-admin/web/src/lib/cache.ts
Normal file
35
cmd/telesrv-admin/web/src/lib/cache.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// 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,3 +193,20 @@ export function parseIDs(value: string, invalidMessage = "msg ids invalid"): num
|
|||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// toUnixSeconds reads a datetime-local input. Such an input carries no zone, so
|
||||
// the value parses as the operator's local time — which is the time they picked.
|
||||
// 0 means "empty or unparseable", which every caller treats as "not scheduled".
|
||||
export function toUnixSeconds(value: string): number {
|
||||
if (!value.trim()) return 0;
|
||||
const ms = new Date(value).getTime();
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
||||
}
|
||||
|
||||
// localInputValue formats a datetime-local default some seconds out, so a
|
||||
// scheduling form never opens on a value the server would reject as past.
|
||||
export function localInputValue(offsetSeconds: number): string {
|
||||
const at = new Date(Date.now() + offsetSeconds * 1000);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}:${pad(at.getMinutes())}`;
|
||||
}
|
||||
|
|
|
|||
423
cmd/telesrv-admin/web/src/pages/AdminUsersPage.tsx
Normal file
423
cmd/telesrv-admin/web/src/pages/AdminUsersPage.tsx
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
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.SentCount + row.FailedCount < row.TotalCount).length;
|
||||
const inFlight = rows.filter((row) => !row.EnumerationDone || row.SentCount + row.FailedCount < row.TargetCount).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.TotalCount > 0 && delivered >= row.TotalCount;
|
||||
const done = row.EnumerationDone && row.TargetCount > 0 && delivered >= row.TargetCount;
|
||||
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.TotalCount}</td>
|
||||
<td>{row.TargetCount}</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 `${base} (${formatCurrency(row.CryptoAmount, row.CryptoCurrency)})`;
|
||||
return `${formatCurrency(row.CryptoAmount, row.CryptoCurrency)} (${base})`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,13 +17,19 @@ 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 }) {
|
||||
const [data, setData] = useState<DashboardResponse | null>(null);
|
||||
// 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 [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -31,6 +37,7 @@ 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");
|
||||
|
|
@ -58,7 +65,8 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
|||
<StatTile
|
||||
icon={<Flag />}
|
||||
label="Pending reports"
|
||||
value={counts ? formatQuantity(String(counts.PendingReports)) : "…"}
|
||||
value={counts ? formatQuantity(String(counts.PendingReports)) : ""}
|
||||
loading={!counts && !error}
|
||||
tone={counts && counts.PendingReports > 0 ? "warn" : "good"}
|
||||
href="/moderation"
|
||||
navigate={navigate}
|
||||
|
|
@ -66,7 +74,8 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
|||
<StatTile
|
||||
icon={<BadgeCheck />}
|
||||
label="Verification requests"
|
||||
value={counts ? formatQuantity(String(counts.PendingVerifications)) : "…"}
|
||||
value={counts ? formatQuantity(String(counts.PendingVerifications)) : ""}
|
||||
loading={!counts && !error}
|
||||
tone={counts && counts.PendingVerifications > 0 ? "warn" : "good"}
|
||||
href="/verification"
|
||||
navigate={navigate}
|
||||
|
|
@ -74,27 +83,44 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
|||
</Section>
|
||||
|
||||
<Section title="People & chats">
|
||||
<StatTile icon={<Users />} label="Users" value={counts ? formatQuantity(String(counts.Users)) : "…"} href="/accounts" navigate={navigate} />
|
||||
<StatTile
|
||||
icon={<Users />}
|
||||
label="Users"
|
||||
value={counts ? formatQuantity(String(counts.Users)) : ""}
|
||||
loading={!counts && !error}
|
||||
href="/accounts"
|
||||
navigate={navigate}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Activity />}
|
||||
label="Online now"
|
||||
value={counts ? formatQuantity(String(counts.OnlineUsers)) : "…"}
|
||||
value={counts ? formatQuantity(String(counts.OnlineUsers)) : ""}
|
||||
loading={!counts && !error}
|
||||
sub="last 5 min"
|
||||
href="/accounts"
|
||||
navigate={navigate}
|
||||
/>
|
||||
<StatTile icon={<Bot />} label="Bots" value={counts ? formatQuantity(String(counts.Bots)) : "…"} href="/bots" navigate={navigate} />
|
||||
<StatTile
|
||||
icon={<Bot />}
|
||||
label="Bots"
|
||||
value={counts ? formatQuantity(String(counts.Bots)) : ""}
|
||||
loading={!counts && !error}
|
||||
href="/bots"
|
||||
navigate={navigate}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Radio />}
|
||||
label="Channels"
|
||||
value={counts ? formatQuantity(String(counts.BroadcastChannels)) : "…"}
|
||||
value={counts ? formatQuantity(String(counts.BroadcastChannels)) : ""}
|
||||
loading={!counts && !error}
|
||||
href="/channels"
|
||||
navigate={navigate}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<UsersRound />}
|
||||
label="Supergroups"
|
||||
value={counts ? formatQuantity(String(counts.Supergroups)) : "…"}
|
||||
value={counts ? formatQuantity(String(counts.Supergroups)) : ""}
|
||||
loading={!counts && !error}
|
||||
href="/channels"
|
||||
navigate={navigate}
|
||||
/>
|
||||
|
|
@ -104,21 +130,24 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
|||
<StatTile
|
||||
icon={<Sticker />}
|
||||
label="Sticker packs"
|
||||
value={counts ? formatQuantity(String(counts.StickerSets)) : "…"}
|
||||
value={counts ? formatQuantity(String(counts.StickerSets)) : ""}
|
||||
loading={!counts && !error}
|
||||
href="/stickers"
|
||||
navigate={navigate}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Smile />}
|
||||
label="Emoji packs"
|
||||
value={counts ? formatQuantity(String(counts.EmojiSets)) : "…"}
|
||||
value={counts ? formatQuantity(String(counts.EmojiSets)) : ""}
|
||||
loading={!counts && !error}
|
||||
href="/emoji"
|
||||
navigate={navigate}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Film />}
|
||||
label="GIFs"
|
||||
value={counts ? formatQuantity(String(counts.Gifs)) : "…"}
|
||||
value={counts ? formatQuantity(String(counts.Gifs)) : ""}
|
||||
loading={!counts && !error}
|
||||
sub="saved by users"
|
||||
href="/gif-catalog"
|
||||
navigate={navigate}
|
||||
|
|
@ -126,7 +155,8 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
|||
<StatTile
|
||||
icon={<Database />}
|
||||
label="Media storage used"
|
||||
value={storage ? formatBytes(storage.PhysicalBytes) : "…"}
|
||||
value={storage ? formatBytes(storage.PhysicalBytes) : ""}
|
||||
loading={!storage && !error}
|
||||
sub={storage ? `${storage.BackendKind} backend` : undefined}
|
||||
href="/storage"
|
||||
navigate={navigate}
|
||||
|
|
@ -138,25 +168,32 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
|||
icon={<Cpu />}
|
||||
label="CPU load"
|
||||
percent={host?.Ready ? host.CPUPercent : undefined}
|
||||
valueText={host?.Ready ? `${host.CPUPercent.toFixed(0)}%` : "…"}
|
||||
valueText={host?.Ready ? `${host.CPUPercent.toFixed(0)}%` : ""}
|
||||
loading={!host?.Ready && !error}
|
||||
/>
|
||||
<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)) : "…"}
|
||||
valueText={host?.Ready ? formatBytes(String(host.MemUsedBytes)) : ""}
|
||||
loading={!host?.Ready && !error}
|
||||
sub={host?.Ready ? `of ${formatBytes(String(host.MemTotalBytes))}` : undefined}
|
||||
/>
|
||||
<UsageTile
|
||||
icon={<HardDrive />}
|
||||
label="Disk free"
|
||||
percent={
|
||||
host?.Ready && host.DiskTotalBytes > 0
|
||||
host?.Ready && host.DiskReady && host.DiskTotalBytes > 0
|
||||
? ((host.DiskTotalBytes - host.DiskFreeBytes) / host.DiskTotalBytes) * 100
|
||||
: undefined
|
||||
}
|
||||
valueText={host?.Ready ? formatBytes(String(host.DiskFreeBytes)) : "…"}
|
||||
sub={host?.Ready ? `of ${formatBytes(String(host.DiskTotalBytes))}` : 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"}
|
||||
warnAbove={85}
|
||||
/>
|
||||
</Section>
|
||||
|
|
@ -185,7 +222,8 @@ function StatTile({
|
|||
sub,
|
||||
tone = "neutral",
|
||||
href,
|
||||
navigate
|
||||
navigate,
|
||||
loading = false
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
|
|
@ -194,6 +232,7 @@ function StatTile({
|
|||
tone?: Tone;
|
||||
href?: string;
|
||||
navigate?: Navigate;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const toneClass = tone === "neutral" ? "" : ` ${tone}`;
|
||||
const body = (
|
||||
|
|
@ -202,7 +241,9 @@ function StatTile({
|
|||
<span className="stat-tile-icon">{icon}</span>
|
||||
{tone === "warn" && <AlertTriangle size={15} className="stat-tile-open" />}
|
||||
</div>
|
||||
<div className="stat-tile-value">{value}</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-label">{label}</div>
|
||||
{sub && <div className="stat-tile-sub">{sub}</div>}
|
||||
</>
|
||||
|
|
@ -233,7 +274,8 @@ function UsageTile({
|
|||
percent,
|
||||
valueText,
|
||||
sub,
|
||||
warnAbove = 90
|
||||
warnAbove = 90,
|
||||
loading = false
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
|
|
@ -241,6 +283,7 @@ 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";
|
||||
|
|
@ -250,7 +293,9 @@ function UsageTile({
|
|||
<div className="stat-tile-head">
|
||||
<span className="stat-tile-icon">{icon}</span>
|
||||
</div>
|
||||
<div className="stat-tile-value">{valueText}</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-label">{label}</div>
|
||||
{sub && <div className="stat-tile-sub">{sub}</div>}
|
||||
<div className="stat-tile-bar">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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";
|
||||
|
|
@ -43,7 +44,7 @@ function EmojiCard({ row }: { row: EmojiRow }) {
|
|||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(row.DocumentID);
|
||||
await copyToClipboard(row.DocumentID);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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";
|
||||
|
|
@ -38,32 +39,38 @@ 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">
|
||||
<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>
|
||||
<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>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<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={"Views"} value={String(msg.ViewsCount)} />
|
||||
<Summary label={"pts"} value={String(msg.PTS)} mono />
|
||||
</div>
|
||||
<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>
|
||||
<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 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, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { Alert, Badge, EmptyRow, Metric, QueryPanel } from "../components/ui";
|
||||
import { channelKind, formatUnix } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { ChannelRow, GroupMessageListResponse } from "../types";
|
||||
|
||||
export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
|
||||
export function GroupMessagesTab({ navigate }: { navigate: Navigate }) {
|
||||
const [channel, setChannel] = useState<ChannelRow | null>(null);
|
||||
const [beforeDate, setBeforeDate] = useState("");
|
||||
const [beforeID, setBeforeID] = useState("");
|
||||
|
|
@ -52,7 +52,7 @@ export function GroupMessagesPage({ 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 GroupMessagesPage({ navigate }: { navigate: Navigate }) {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PageFrame>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,89 @@
|
|||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, 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 } from "../types";
|
||||
import type { AdminSession, PublicBranding } 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 {
|
||||
// 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 ?? [] });
|
||||
// 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 ?? [] });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
|
|
@ -28,44 +93,74 @@ export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => voi
|
|||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<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>
|
||||
<AppBackground />
|
||||
<section className="login-panel">
|
||||
<div className="login-head">
|
||||
<div className="brand brand-elevated">
|
||||
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
|
||||
<span className="brand-mark">
|
||||
<img src={iconSrc} alt={serverName} onError={() => setIconFailed(true)} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>OwpenGram</strong>
|
||||
<strong>{serverName}</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}>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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";
|
||||
|
|
@ -41,37 +42,46 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
|
|||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<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={"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>
|
||||
<Summary label={"pts"} value={String(msg.PTS)} mono />
|
||||
</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,8 +7,9 @@ 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 MessagesPage({ navigate }: { navigate: Navigate }) {
|
||||
export function PrivateMessagesTab({ navigate }: { navigate: Navigate }) {
|
||||
const [owner, setOwner] = useState<AccountRow | null>(null);
|
||||
const [peer, setPeer] = useState<AccountRow | null>(null);
|
||||
const [beforeDate, setBeforeDate] = useState("");
|
||||
|
|
@ -65,7 +66,7 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<PageFrame title={"Private Messages"} eyebrow={"Private message boxes"}>
|
||||
<>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<QueryPanel>
|
||||
<div className="message-selector-grid">
|
||||
|
|
@ -152,6 +153,42 @@ export function MessagesPage({ 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
176
cmd/telesrv-admin/web/src/pages/ReservedUsernamesPage.tsx
Normal file
176
cmd/telesrv-admin/web/src/pages/ReservedUsernamesPage.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { Loader2, Plus, RefreshCw, Search, Trash2, 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, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { formatUnix } from "../lib/format";
|
||||
import type { ReservedUsernameRow } from "../types";
|
||||
|
||||
// Reserved usernames are a plain operator blocklist: a name listed here cannot be
|
||||
// taken as an editable username by any peer and cannot be minted as a
|
||||
// collectible. No owner, no price, no "bought on Fragment" badge - that is the
|
||||
// collectible tab's job.
|
||||
export function ReservedUsernamesPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [reserveOpen, setReserveOpen] = useState(false);
|
||||
const [rows, setRows] = useState<ReservedUsernameRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit: "200" });
|
||||
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
|
||||
try {
|
||||
const result = await api.reservedUsernames(params);
|
||||
setRows(result.reserved ?? []);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={"Reserved usernames"}
|
||||
eyebrow={"Usernames / Blocklist"}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn primary icon-text" type="button" onClick={() => setReserveOpen(true)}>
|
||||
<Plus size={15} /> {"Reserve username"}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={() => load()} disabled={loading}>
|
||||
<RefreshCw size={15} className={loading ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={"Reserved names"} value={String(rows.length)} />
|
||||
</div>
|
||||
|
||||
<QueryPanel>
|
||||
<form
|
||||
className="toolbar"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Filter by prefix"} />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={loading}>
|
||||
{loading ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Username"}</th>
|
||||
<th>{"Reason"}</th>
|
||||
<th>{"Reserved by"}</th>
|
||||
<th>{"Reserved (UTC)"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.username}>
|
||||
<td><strong>{`@${row.username}`}</strong></td>
|
||||
<td>{row.reason || "-"}</td>
|
||||
<td>{row.actor || "-"}</td>
|
||||
<td>{formatUnix(row.created_at) || "-"}</td>
|
||||
<td>
|
||||
<ActionButton
|
||||
compact
|
||||
label={"Unreserve"}
|
||||
icon={<Trash2 size={13} />}
|
||||
tone="danger"
|
||||
path="/api/actions/unreserve-username"
|
||||
payload={() => ({ username: row.username })}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={5} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{reserveOpen && (
|
||||
<ReserveUsernameModal
|
||||
onClose={() => setReserveOpen(false)}
|
||||
onDone={() => {
|
||||
setReserveOpen(false);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ReserveUsernameModal collects the name, then hands off to ActionButton for the
|
||||
// standard reason / dry-run / confirm flow - the same as every other admin
|
||||
// action. The name is read fresh from state on each ActionButton render.
|
||||
function ReserveUsernameModal({ onClose, onDone }: { onClose: () => void; onDone: () => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const clean = username.trim().replace(/^@/, "");
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Reserve a username"}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{"Usernames"}</div>
|
||||
<h2>{"Reserve a 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="form-field">
|
||||
<span>{"Username"}</span>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="support"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<p className="bot-create-note">
|
||||
{`No peer will be able to take @${clean || "…"} until it is unreserved. Nothing is shown to users.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={onClose}>{"Close"}</button>
|
||||
<ActionButton
|
||||
disabled={clean.length < 5}
|
||||
label={"Reserve username"}
|
||||
icon={<Plus size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/reserve-username"
|
||||
payload={() => ({ username: clean })}
|
||||
onDone={onDone}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { type Navigate, type RouteState } from "../routing";
|
||||
import { AccountDetailPage } from "./AccountDetailPage";
|
||||
import { AccountsPage } from "./AccountsPage";
|
||||
import { SharedDevicesPage } from "./SharedDevicesPage";
|
||||
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
|
||||
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
|
||||
import { ReservedUsernamesPage } from "./ReservedUsernamesPage";
|
||||
import { ChannelDetailPage } from "./ChannelDetailPage";
|
||||
import { ChannelsPage } from "./ChannelsPage";
|
||||
import { BotDetailPage } from "./BotDetailPage";
|
||||
|
|
@ -11,11 +13,12 @@ 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";
|
||||
|
|
@ -26,11 +29,30 @@ 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];
|
||||
|
|
@ -43,8 +65,8 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
const botVerificationRequestID = route.path.match(/^\/bot-verification\/(\d+)$/)?.[1];
|
||||
if (botVerificationRequestID) {
|
||||
return (
|
||||
<ThirdPartyVerificationHiddenGate>
|
||||
<PermissionGate permission={permissionBotVerificationReview}>
|
||||
<ThirdPartyVerificationHiddenGate navigate={navigate}>
|
||||
<PermissionGate navigate={navigate} permission={permissionBotVerificationReview}>
|
||||
<BotVerificationRequestPage id={botVerificationRequestID} navigate={navigate} />
|
||||
</PermissionGate>
|
||||
</ThirdPartyVerificationHiddenGate>
|
||||
|
|
@ -52,8 +74,8 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
}
|
||||
if (route.path === "/bot-verification") {
|
||||
return (
|
||||
<ThirdPartyVerificationHiddenGate>
|
||||
<PermissionGate permission={permissionBotVerificationReview}>
|
||||
<ThirdPartyVerificationHiddenGate navigate={navigate}>
|
||||
<PermissionGate navigate={navigate} permission={permissionBotVerificationReview}>
|
||||
<BotVerificationPage navigate={navigate} />
|
||||
</PermissionGate>
|
||||
</ThirdPartyVerificationHiddenGate>
|
||||
|
|
@ -64,89 +86,111 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
// itself instead of rendering an empty queue.
|
||||
if (verificationID) {
|
||||
return (
|
||||
<PermissionGate permission={permissionVerificationReview}>
|
||||
<PermissionGate navigate={navigate} permission={permissionVerificationReview}>
|
||||
<VerificationDetailPage id={verificationID} navigate={navigate} />
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
if (route.path === "/verification") {
|
||||
return (
|
||||
<PermissionGate permission={permissionVerificationReview}>
|
||||
<PermissionGate navigate={navigate} permission={permissionVerificationReview}>
|
||||
<VerificationPage navigate={navigate} />
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
if (collectibleUsernameID) {
|
||||
return <CollectibleUsernameDetailPage id={collectibleUsernameID} navigate={navigate} />;
|
||||
return gate(permissionUsernamesRead, <CollectibleUsernameDetailPage id={collectibleUsernameID} navigate={navigate} />);
|
||||
}
|
||||
if (route.path === "/collectible-usernames") {
|
||||
return <CollectibleUsernamesPage navigate={navigate} />;
|
||||
return gate(permissionUsernamesRead, <CollectibleUsernamesPage navigate={navigate} />);
|
||||
}
|
||||
if (route.path === "/reserved-usernames") {
|
||||
return <ReservedUsernamesPage />;
|
||||
}
|
||||
if (route.path === "/storage") {
|
||||
return <StoragePage navigate={navigate} />;
|
||||
return gate(permissionStorageRead, <StoragePage navigate={navigate} />);
|
||||
}
|
||||
if (accountID) {
|
||||
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
|
||||
return gate(permissionAccountsRead, <AccountDetailPage id={Number(accountID)} navigate={navigate} />);
|
||||
}
|
||||
if (channelID) {
|
||||
return <ChannelDetailPage id={Number(channelID)} navigate={navigate} />;
|
||||
return gate(permissionChannelsRead, <ChannelDetailPage id={Number(channelID)} navigate={navigate} />);
|
||||
}
|
||||
if (botID) {
|
||||
return <BotDetailPage id={Number(botID)} navigate={navigate} />;
|
||||
return gate(permissionBotsRead, <BotDetailPage id={Number(botID)} navigate={navigate} />);
|
||||
}
|
||||
if (moderationCaseID) {
|
||||
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />;
|
||||
return gate(permissionModerationReview, <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />);
|
||||
}
|
||||
if (route.path === "/accounts/shared-devices") {
|
||||
return <SharedDevicesPage navigate={navigate} />;
|
||||
return gate(permissionAccountsRead, <SharedDevicesPage navigate={navigate} />);
|
||||
}
|
||||
if (route.path === "/accounts") {
|
||||
return <AccountsPage navigate={navigate} />;
|
||||
return gate(permissionAccountsRead, <AccountsPage navigate={navigate} />);
|
||||
}
|
||||
if (route.path === "/channels") {
|
||||
return <ChannelsPage navigate={navigate} />;
|
||||
return gate(permissionChannelsRead, <ChannelsPage navigate={navigate} />);
|
||||
}
|
||||
if (route.path === "/bots") {
|
||||
return <BotsPage navigate={navigate} />;
|
||||
return gate(permissionBotsRead, <BotsPage navigate={navigate} />);
|
||||
}
|
||||
if (route.path === "/moderation") {
|
||||
return <ModerationCasesPage navigate={navigate} />;
|
||||
return gate(permissionModerationReview, <ModerationCasesPage navigate={navigate} />);
|
||||
}
|
||||
if (route.path === "/broadcasts") {
|
||||
return <BroadcastsPage />;
|
||||
return gate(permissionBroadcastsRead, <BroadcastsPage />);
|
||||
}
|
||||
if (route.path === "/emoji") {
|
||||
return <StickerSetsPage kind="emoji" />;
|
||||
return gate(permissionContentRead, <StickerSetsPage kind="emoji" />);
|
||||
}
|
||||
if (route.path === "/stickers") {
|
||||
return <StickerSetsPage kind="stickers" />;
|
||||
return gate(permissionContentRead, <StickerSetsPage kind="stickers" />);
|
||||
}
|
||||
if (route.path === "/gif-catalog") {
|
||||
return <GifCatalogPage />;
|
||||
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>
|
||||
);
|
||||
}
|
||||
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
|
||||
return (
|
||||
return gate(permissionMessagesRead, (
|
||||
<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 (
|
||||
return gate(permissionMessagesRead, (
|
||||
<GroupMessageDetailPage
|
||||
channelID={Number(route.search.get("channel_id") || "0")}
|
||||
msgID={Number(route.search.get("msg_id") || "0")}
|
||||
navigate={navigate}
|
||||
/>
|
||||
);
|
||||
));
|
||||
}
|
||||
if (route.path === "/messages/groups") {
|
||||
return <GroupMessagesPage 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" || route.path === "/messages/private") {
|
||||
return <MessagesPage navigate={navigate} />;
|
||||
}
|
||||
return <Dashboard navigate={navigate} />;
|
||||
return gate(permissionDashboardRead, <Dashboard navigate={navigate} />);
|
||||
}
|
||||
|
|
|
|||
818
cmd/telesrv-admin/web/src/pages/ServerSettingsPage.tsx
Normal file
818
cmd/telesrv-admin/web/src/pages/ServerSettingsPage.tsx
Normal file
|
|
@ -0,0 +1,818 @@
|
|||
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,24 +1,74 @@
|
|||
import { ChevronDown, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
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 { api, errorMessage } from "../api";
|
||||
import { Alert, EmptyRow, Metric, PageFrame } from "../components/ui";
|
||||
import { cacheGet, cacheKeys, cacheSet } from "../lib/cache";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, EmptyRow, LoadingRow, Metric, PageFrame, QueryPanel, SectionHead } from "../components/ui";
|
||||
import { displayUsername, formatBytes, formatQuantity } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountStorageRow, StorageStatsResponse } from "../types";
|
||||
|
||||
export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
|
||||
const [stats, setStats] = useState<StorageStatsResponse | null>(null);
|
||||
const [rows, setRows] = useState<AccountStorageRow[]>([]);
|
||||
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) ?? []
|
||||
);
|
||||
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 {
|
||||
setStats(await api.storageStats());
|
||||
const next = await api.storageStats();
|
||||
cacheSet(cacheKeys.storageStats, next);
|
||||
setStats(next);
|
||||
setStatsFailed(false);
|
||||
} catch {
|
||||
// Stats are a header nicety; a failure here shouldn't block the list.
|
||||
setStatsFailed(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -26,11 +76,26 @@ export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
|
|||
setBusy(true);
|
||||
setError("");
|
||||
const at = next ? offset : 0;
|
||||
const params = new URLSearchParams({ limit: "50", offset: String(at) });
|
||||
const params = new URLSearchParams({
|
||||
limit: "50",
|
||||
offset: String(at),
|
||||
sort: sortKey,
|
||||
order: sortDesc ? "desc" : "asc"
|
||||
});
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
try {
|
||||
const result = await api.storageAccounts(params);
|
||||
const page = result.rows ?? [];
|
||||
setRows((current) => (next ? [...current, ...page] : page));
|
||||
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;
|
||||
});
|
||||
setOffset(result.next_offset);
|
||||
setHasMore(Boolean(result.has_more));
|
||||
} catch (err) {
|
||||
|
|
@ -48,7 +113,16 @@ export function StoragePage({ navigate: _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
|
||||
|
|
@ -56,41 +130,69 @@ export function StoragePage({ navigate: _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) : "-"} />
|
||||
<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 ?? "-"} />
|
||||
<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} />
|
||||
</div>
|
||||
<div className="metric-row">
|
||||
<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={"Documents"} value={stats ? formatQuantity(stats.DocumentCount) : "-"} loading={!stats && !statsFailed} />
|
||||
<Metric label={"Photos"} value={stats ? formatQuantity(stats.PhotoCount) : "-"} loading={!stats && !statsFailed} />
|
||||
<Metric
|
||||
label={"Unattributed"}
|
||||
value={stats ? formatBytes(stats.UnattributedBytes) : "-"}
|
||||
tone={stats && Number(stats.UnattributedBytes) > 0 ? "warn" : "neutral"}
|
||||
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}
|
||||
/>
|
||||
</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>
|
||||
<th>{"User ID"}</th>
|
||||
<th>{"Account"}</th>
|
||||
<th>{"Storage used"}</th>
|
||||
<th>{"Files"}</th>
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -100,9 +202,10 @@ export function StoragePage({ navigate: _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 && <EmptyRow colSpan={4} />}
|
||||
{rows.length === 0 && (busy ? <LoadingRow colSpan={5} /> : <EmptyRow colSpan={5} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -113,6 +216,654 @@ export function StoragePage({ navigate: _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,10 +1,13 @@
|
|||
import { ShieldOff } from "lucide-react";
|
||||
import { EyeOff, ShieldOff } from "lucide-react";
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import { Alert, PageFrame } from "./components/ui";
|
||||
import { StatusScreen } from "./components/StatusScreen";
|
||||
import type { Navigate } from "./routing";
|
||||
// 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
|
||||
|
|
@ -12,6 +15,25 @@ 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
|
||||
|
|
@ -64,51 +86,178 @@ export function useThirdPartyVerificationHidden(): boolean {
|
|||
}
|
||||
|
||||
// PermissionGate is what a direct URL hits: without the right the operator gets
|
||||
// an explanation naming the missing permission, not an empty table that looks
|
||||
// a proper refusal naming the missing permission, not an empty table that looks
|
||||
// like "no data".
|
||||
export function PermissionGate({ permission, children }: { permission: string; children: ReactNode }) {
|
||||
export function PermissionGate({
|
||||
permission,
|
||||
navigate,
|
||||
children
|
||||
}: {
|
||||
permission: string;
|
||||
navigate?: Navigate;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { can } = usePermissions();
|
||||
if (can(permission)) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return <PermissionDenied permission={permission} />;
|
||||
return <PermissionDenied permission={permission} navigate={navigate} />;
|
||||
}
|
||||
|
||||
export function PermissionDenied({ permission }: { permission: string }) {
|
||||
export function PermissionDenied({ permission, navigate }: { permission: string; navigate?: Navigate }) {
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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({ children }: { children: ReactNode }) {
|
||||
export function ThirdPartyVerificationHiddenGate({
|
||||
navigate,
|
||||
children
|
||||
}: {
|
||||
navigate?: Navigate;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const hidden = useThirdPartyVerificationHidden();
|
||||
if (!hidden) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export function routeTitle(pathname: string): string {
|
|||
if (pathname.startsWith("/bot-verification")) return "Third-party verification";
|
||||
if (pathname.startsWith("/verification")) return "Official Verification";
|
||||
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
|
||||
if (pathname.startsWith("/reserved-usernames")) return "Reserved Usernames";
|
||||
if (pathname.startsWith("/storage")) return "Storage";
|
||||
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
|
||||
if (pathname.startsWith("/accounts")) return "Accounts";
|
||||
|
|
@ -31,5 +32,6 @@ 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,6 +13,11 @@
|
|||
--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);
|
||||
|
|
@ -100,6 +105,8 @@
|
|||
--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);
|
||||
|
|
@ -231,6 +238,8 @@ a {
|
|||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
|
|
@ -238,7 +247,7 @@ a {
|
|||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
|
|
@ -254,6 +263,35 @@ 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);
|
||||
|
|
@ -263,6 +301,14 @@ 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;
|
||||
|
|
@ -436,8 +482,11 @@ 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 10px;
|
||||
padding: 0 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-soft);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
|
|
@ -457,3 +506,61 @@ 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,6 +111,52 @@ 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);
|
||||
}
|
||||
|
|
@ -779,6 +825,27 @@ 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);
|
||||
}
|
||||
|
|
@ -869,3 +936,251 @@ 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,6 +206,40 @@
|
|||
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;
|
||||
|
|
@ -928,3 +962,467 @@
|
|||
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,6 +63,33 @@
|
|||
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;
|
||||
|
|
@ -245,21 +272,25 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-page .bg-orbs {
|
||||
.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;
|
||||
}
|
||||
|
||||
.login-page .bg-orb {
|
||||
.bg-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-page .bg-orb--1 {
|
||||
.bg-orb--1 {
|
||||
top: -15%;
|
||||
left: -10%;
|
||||
width: 700px;
|
||||
|
|
@ -268,7 +299,7 @@
|
|||
animation: loginOrbFloat1 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.login-page .bg-orb--2 {
|
||||
.bg-orb--2 {
|
||||
top: 25%;
|
||||
right: -15%;
|
||||
width: 600px;
|
||||
|
|
@ -277,7 +308,7 @@
|
|||
animation: loginOrbFloat2 24s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.login-page .bg-orb--3 {
|
||||
.bg-orb--3 {
|
||||
bottom: -15%;
|
||||
left: 30%;
|
||||
width: 500px;
|
||||
|
|
@ -303,14 +334,15 @@
|
|||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.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; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.login-page .bg-orb { animation: none; }
|
||||
.bg-orb { animation: none; }
|
||||
.bg-orbs { transition: none; }
|
||||
}
|
||||
|
||||
.login-page .login-panel {
|
||||
|
|
@ -320,13 +352,19 @@
|
|||
|
||||
.login-panel {
|
||||
display: grid;
|
||||
width: min(420px, 100%);
|
||||
gap: 18px;
|
||||
padding: 22px;
|
||||
width: min(480px, 100%);
|
||||
gap: 22px;
|
||||
padding: 30px;
|
||||
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 {
|
||||
|
|
@ -344,6 +382,25 @@
|
|||
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;
|
||||
|
|
@ -356,17 +413,6 @@
|
|||
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;
|
||||
|
|
@ -381,6 +427,132 @@
|
|||
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;
|
||||
|
|
@ -425,3 +597,96 @@
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -347,6 +347,17 @@ export type CollectibleUsernameDetail = {
|
|||
transfers: CollectibleUsernameTransferRow[] | null;
|
||||
};
|
||||
|
||||
export type ReservedUsernameRow = {
|
||||
username: string;
|
||||
reason: string;
|
||||
actor: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export type ReservedUsernameListResponse = {
|
||||
reserved: ReservedUsernameRow[] | null;
|
||||
};
|
||||
|
||||
// Official platform verification. Every int64 the backend tags `,string` stays a
|
||||
// decimal string here: application ids, peer ids and the optimistic-locking
|
||||
// version all outgrow the exact range of a JSON number, and a rounded version
|
||||
|
|
@ -580,6 +591,28 @@ 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 & {
|
||||
|
|
@ -710,7 +743,7 @@ export type SharedDeviceGroupListResponse = {
|
|||
export type StorageStatsResponse = {
|
||||
PhysicalBytes: string;
|
||||
LogicalBytes: string;
|
||||
UnattributedBytes: string;
|
||||
SystemBytes: string;
|
||||
DocumentCount: string;
|
||||
PhotoCount: string;
|
||||
AccountCount: string;
|
||||
|
|
@ -736,6 +769,11 @@ 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;
|
||||
};
|
||||
|
||||
|
|
@ -782,9 +820,11 @@ export type BroadcastRow = {
|
|||
ID: number;
|
||||
Message: string;
|
||||
TargetMode: string;
|
||||
TotalCount: number;
|
||||
TargetCount: number;
|
||||
MaterializedCount: number;
|
||||
SentCount: number;
|
||||
FailedCount: number;
|
||||
EnumerationDone: boolean;
|
||||
CreatedBy: string;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
|
@ -829,3 +869,94 @@ 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,6 +38,16 @@ func run(ctx context.Context, args []string) error {
|
|||
return runKeygen(args[1:])
|
||||
case "provision":
|
||||
return runProvision(ctx, args[1:])
|
||||
case "plan-dataset":
|
||||
return runPlanDataset(args[1:])
|
||||
case "seed":
|
||||
return runSeed(ctx, args[1:])
|
||||
case "snapshot":
|
||||
return runSnapshot(ctx, args[1:])
|
||||
case "mutate-offline":
|
||||
return runMutateOffline(ctx, args[1:])
|
||||
case "startup-run":
|
||||
return runStartup(ctx, args[1:])
|
||||
case "run":
|
||||
return runLoad(ctx, args[1:])
|
||||
case "summarize":
|
||||
|
|
@ -50,6 +60,231 @@ func run(ctx context.Context, args []string) error {
|
|||
}
|
||||
}
|
||||
|
||||
func runPlanDataset(args []string) error {
|
||||
flags := flag.NewFlagSet("plan-dataset", flag.ContinueOnError)
|
||||
out := flags.String("out", filepath.FromSlash("data/loadtest/dataset.json"), "owner-only immutable dataset plan")
|
||||
accounts := flags.Int("accounts", 1000, "logical primary accounts in the provisioned manifest")
|
||||
seed := flags.Int64("seed", 20260827, "deterministic topology and idempotency seed")
|
||||
privateFanout := flags.Int("private-fanout", -1, "outgoing private messages per account; -1 uses min(10, accounts-1)")
|
||||
hotGroups := flags.Int("hot-groups", 10, "hot supergroup count")
|
||||
hotMembers := flags.Int("hot-members", 0, "members per hot supergroup; 0 uses all accounts")
|
||||
hotHistory := flags.Int("hot-history", 100, "messages per hot supergroup")
|
||||
mediumGroups := flags.Int("medium-groups", 100, "medium supergroup count")
|
||||
mediumMembers := flags.Int("medium-members", 100, "members per medium supergroup")
|
||||
mediumHistory := flags.Int("medium-history", 30, "messages per medium supergroup")
|
||||
smallGroups := flags.Int("small-groups", 200, "small supergroup count")
|
||||
smallMembers := flags.Int("small-members", 20, "members per small supergroup")
|
||||
smallHistory := flags.Int("small-history", 10, "messages per small supergroup")
|
||||
heavyGroups := flags.Int("heavy-groups", 200, "heavy-user supergroup count")
|
||||
heavyAccounts := flags.Int("heavy-accounts", 100, "accounts included in every heavy supergroup")
|
||||
heavyHistory := flags.Int("heavy-history", 30, "messages per heavy supergroup")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("plan-dataset accepts no positional arguments")
|
||||
}
|
||||
if *hotMembers == 0 {
|
||||
*hotMembers = *accounts
|
||||
}
|
||||
if *privateFanout == -1 {
|
||||
*privateFanout = min(10, max(*accounts-1, 0))
|
||||
}
|
||||
cfg := loadharness.DatasetConfig{
|
||||
Accounts: *accounts, Seed: *seed, PrivateFanout: *privateFanout,
|
||||
HotGroups: *hotGroups, HotMembers: *hotMembers, HotHistory: *hotHistory,
|
||||
MediumGroups: *mediumGroups, MediumMembers: min(*mediumMembers, *accounts), MediumHistory: *mediumHistory,
|
||||
SmallGroups: *smallGroups, SmallMembers: min(*smallMembers, *accounts), SmallHistory: *smallHistory,
|
||||
HeavyGroups: *heavyGroups, HeavyAccounts: min(*heavyAccounts, *accounts), HeavyHistory: *heavyHistory,
|
||||
}
|
||||
if _, err := os.Stat(*out); err == nil {
|
||||
existing, loadErr := loadharness.LoadDataset(*out)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if existing.Config != cfg {
|
||||
return fmt.Errorf("refusing to replace existing dataset plan %s with different config", *out)
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "dataset plan already exists at %s hash=%s groups=%d private_messages=%d\n",
|
||||
*out, existing.PlanSHA256, len(existing.Groups), len(existing.PrivateEdges))
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
dataset, err := loadharness.PlanDataset(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := loadharness.WriteDataset(*out, dataset); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "dataset plan written to %s hash=%s groups=%d private_messages=%d\n",
|
||||
*out, dataset.PlanSHA256, len(dataset.Groups), len(dataset.PrivateEdges))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSeed(ctx context.Context, args []string) error {
|
||||
flags := flag.NewFlagSet("seed", flag.ContinueOnError)
|
||||
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
|
||||
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
|
||||
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
|
||||
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
|
||||
state := flags.String("state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "resumable seed journal")
|
||||
concurrency := flags.Int("concurrency", 8, "parallel account workers (max 64)")
|
||||
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one seed RPC")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("seed accepts no positional arguments")
|
||||
}
|
||||
result, err := loadharness.Seed(ctx, loadharness.SeedConfig{
|
||||
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||
DatasetPath: *dataset, SeedStatePath: *state, Concurrency: *concurrency, OperationTimeout: *operationTimeout,
|
||||
}, func(event loadharness.SeedEvent) {
|
||||
status := "ok"
|
||||
if event.Err != nil {
|
||||
status = "error"
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "seed phase=%s %d/%d account=%d status=%s\n",
|
||||
event.Phase, event.Completed, event.Total, event.Account, status)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "seed complete private_messages=%d supergroups=%d invited_members=%d group_messages=%d rich_state_accounts=%d state=%s\n",
|
||||
result.PrivateMessages, result.Groups, result.InvitedMembers, result.GroupMessages, result.RichStateAccounts, *state)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSnapshot(ctx context.Context, args []string) error {
|
||||
flags := flag.NewFlagSet("snapshot", flag.ContinueOnError)
|
||||
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
|
||||
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
|
||||
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
|
||||
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
|
||||
seedState := flags.String("seed-state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "completed seed journal")
|
||||
clientState := flags.String("client-state", filepath.FromSlash("data/loadtest/client-state.json"), "baseline account/dialog/PTS snapshot")
|
||||
concurrency := flags.Int("concurrency", 8, "parallel account workers (max 64)")
|
||||
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one snapshot RPC")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("snapshot accepts no positional arguments")
|
||||
}
|
||||
result, err := loadharness.SnapshotClientState(ctx, loadharness.SnapshotConfig{
|
||||
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||
DatasetPath: *dataset, SeedStatePath: *seedState, ClientStatePath: *clientState,
|
||||
Concurrency: *concurrency, OperationTimeout: *operationTimeout,
|
||||
}, func(event loadharness.SnapshotEvent) {
|
||||
status := "ok"
|
||||
if event.Resumed {
|
||||
status = "resumed"
|
||||
}
|
||||
if event.Err != nil {
|
||||
status = "error"
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "snapshot %d/%d account=%d status=%s\n", event.Completed, event.Total, event.Account, status)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "snapshot complete accounts=%d dialogs=%d channel_dialogs=%d client_state=%s\n",
|
||||
result.Accounts, result.Dialogs, result.Channels, *clientState)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMutateOffline(ctx context.Context, args []string) error {
|
||||
flags := flag.NewFlagSet("mutate-offline", flag.ContinueOnError)
|
||||
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
|
||||
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
|
||||
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
|
||||
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
|
||||
seedState := flags.String("seed-state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "completed seed journal")
|
||||
clientState := flags.String("client-state", filepath.FromSlash("data/loadtest/client-state.json"), "immutable old account/channel PTS snapshot")
|
||||
mutationState := flags.String("mutation-state", filepath.FromSlash("data/loadtest/offline-mutation-state.json"), "resumable offline mutation journal")
|
||||
concurrency := flags.Int("concurrency", 8, "parallel writer accounts (max 64)")
|
||||
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one mutation RPC")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("mutate-offline accepts no positional arguments")
|
||||
}
|
||||
result, err := loadharness.MutateOffline(ctx, loadharness.MutateOfflineConfig{
|
||||
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||
DatasetPath: *dataset, SeedStatePath: *seedState, ClientStatePath: *clientState,
|
||||
MutationStatePath: *mutationState, Concurrency: *concurrency, OperationTimeout: *operationTimeout,
|
||||
}, func(event loadharness.MutationEvent) {
|
||||
status := "ok"
|
||||
if event.Err != nil {
|
||||
status = "error"
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "mutate phase=%s %d/%d account=%d status=%s\n",
|
||||
event.Phase, event.Completed, event.Total, event.Account, status)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "offline mutation complete private_messages=%d dirty_channels=%d channel_messages=%d edited=%d deleted=%d pinned=%d state=%s\n",
|
||||
result.PrivateMessages, result.DirtyChannels, result.ChannelMessages, result.Edited, result.Deleted, result.Pinned, *mutationState)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runStartup(ctx context.Context, args []string) error {
|
||||
flags := flag.NewFlagSet("startup-run", flag.ContinueOnError)
|
||||
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
|
||||
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
|
||||
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
|
||||
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
|
||||
seedState := flags.String("seed-state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "completed seed journal")
|
||||
clientState := flags.String("client-state", filepath.FromSlash("data/loadtest/client-state.json"), "immutable old account/channel PTS snapshot")
|
||||
mutationState := flags.String("mutation-state", filepath.FromSlash("data/loadtest/offline-mutation-state.json"), "completed offline mutation journal")
|
||||
report := flags.String("report", filepath.FromSlash("data/loadtest/startup-report.json"), "startup correctness and latency report")
|
||||
events := flags.String("events", filepath.FromSlash("data/loadtest/startup-events.ndjson"), "periodic owner-only startup and server metric evidence")
|
||||
serverMetrics := flags.String("server-metrics", "http://127.0.0.1:6060/metrics", "server metrics URL; empty disables")
|
||||
profile := flags.String("profile", loadharness.StartupProfileTDesktopReturningV1, "startup workload: tdesktop-cold-returning-v1 or tdlib-returning-v1")
|
||||
startOrder := flags.String("start-order", loadharness.StartupOrderShuffled, "account launch order: shuffled or account-index")
|
||||
startOrderSeed := flags.Int64("start-order-seed", 0, "deterministic shuffled launch seed; 0 uses the dataset seed")
|
||||
accounts := flags.Int("accounts", 0, "limit first N accounts; 0 uses the complete dataset")
|
||||
ramp := flags.Duration("ramp", 30*time.Second, "connection start ramp duration")
|
||||
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one startup RPC")
|
||||
sampleInterval := flags.Duration("sample-interval", 2*time.Second, "server resource sampling interval")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("startup-run accepts no positional arguments")
|
||||
}
|
||||
result, err := loadharness.StartupRun(ctx, loadharness.StartupRunConfig{
|
||||
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||
DatasetPath: *dataset, SeedStatePath: *seedState, ClientStatePath: *clientState,
|
||||
MutationStatePath: *mutationState, ReportPath: *report, EventsPath: *events, ServerMetricsURL: *serverMetrics,
|
||||
Profile: *profile, StartOrder: *startOrder, StartOrderSeed: *startOrderSeed,
|
||||
AccountLimit: *accounts, RampDuration: *ramp, OperationTimeout: *operationTimeout,
|
||||
SampleInterval: *sampleInterval,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printStartupSummary(result)
|
||||
if !result.Pass {
|
||||
return fmt.Errorf("startup acceptance failed; see %s", *report)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printStartupSummary(report *loadharness.StartupRunReport) {
|
||||
fmt.Fprintf(os.Stdout, "pass=%v business_ready=%d/%d dialogs=%d channel_dialogs=%d account_diff_calls=%d channel_diff_calls=%d channel_full=%d channel_too_long=%d channel_empty=%d\n",
|
||||
report.Pass, report.BusinessReady, report.ExpectedAccounts, report.DialogsObserved, report.ChannelDialogs,
|
||||
report.AccountDifference.Calls, report.ChannelDifference.Calls, report.ChannelDifference.Full,
|
||||
report.ChannelDifference.TooLong, report.ChannelDifference.Empty)
|
||||
for _, failure := range report.Failures {
|
||||
fmt.Fprintln(os.Stdout, "failure:", failure)
|
||||
}
|
||||
}
|
||||
|
||||
func runKeygen(args []string) error {
|
||||
flags := flag.NewFlagSet("keygen", flag.ContinueOnError)
|
||||
path := flags.String("out", filepath.FromSlash("data/loadtest/session.key"), "owner-only session encryption key file")
|
||||
|
|
@ -78,7 +313,7 @@ func runProvision(ctx context.Context, args []string) error {
|
|||
accounts := flags.Int("accounts", 450, "unique accounts")
|
||||
extraDevices := flags.Int("extra-devices", 50, "accounts receiving a second independent session")
|
||||
concurrency := flags.Int("concurrency", 8, "parallel provisioning workers (max 64)")
|
||||
phonePrefix := flags.String("phone-prefix", "+155500", "E.164 prefix followed by a six-digit account index")
|
||||
phonePrefix := flags.String("phone-prefix", loadharness.DefaultPhonePrefix, "possible reserved NANP prefix followed by a six-digit account index")
|
||||
firstName := flags.String("first-name-prefix", "Load", "generated first-name prefix")
|
||||
obfuscated := flags.Bool("obfuscated", true, "use TDesktop-like Obfuscated2 + abridged transport")
|
||||
pfs := flags.Bool("pfs", true, "bind temporary auth keys using PFS")
|
||||
|
|
@ -129,12 +364,17 @@ func runLoad(ctx context.Context, args []string) error {
|
|||
events := flags.String("events", filepath.FromSlash("data/loadtest/events.ndjson"), "periodic NDJSON evidence")
|
||||
fileFixture := flags.String("file-fixture", "", "reusable fixture JSON; empty stores beside manifest")
|
||||
serverMetrics := flags.String("server-metrics", "http://127.0.0.1:6060/metrics", "server metrics URL; empty disables")
|
||||
startOrder := flags.String("start-order", loadharness.StartupOrderShuffled, "session launch order: shuffled or account-index")
|
||||
startOrderSeed := flags.Int64("start-order-seed", 20260827, "deterministic shuffled launch seed")
|
||||
sessions := flags.Int("sessions", 0, "limit selected sessions; 0 uses all")
|
||||
duration := flags.Duration("duration", 30*time.Minute, "sustained load duration")
|
||||
recovery := flags.Duration("recovery", 7*time.Minute, "post-disconnect reclamation observation")
|
||||
ramp := flags.Duration("ramp", 2*time.Minute, "connection ramp duration")
|
||||
rpcInterval := flags.Duration("rpc-interval", 5*time.Second, "per-session background RPC interval")
|
||||
messageInterval := flags.Duration("message-interval", 30*time.Second, "per-primary-session message interval; negative disables")
|
||||
messageRate := flags.Float64("message-rate", 0, "aggregate fixed arrival rate in messages/second; use with message-interval=-1")
|
||||
messageQueue := flags.Int("message-queue", 8, "bounded pending sends per primary session for fixed-rate workload")
|
||||
deliverySettle := flags.Duration("delivery-settle", 10*time.Second, "maximum live-delivery settle time before final updates.getDifference reconciliation")
|
||||
fileInterval := flags.Duration("file-interval", time.Minute, "per-session upload.getFile interval")
|
||||
fileSize := flags.Int("file-size", 4<<20, "generated shared download fixture bytes; 0 disables")
|
||||
fileChunk := flags.Int("file-chunk", 1<<20, "upload.getFile bytes per request (max 1MiB)")
|
||||
|
|
@ -155,8 +395,10 @@ func runLoad(ctx context.Context, args []string) error {
|
|||
result, err := loadharness.Run(ctx, loadharness.RunConfig{
|
||||
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||
ReportPath: *report, EventsPath: *events, FileFixturePath: *fileFixture, ServerMetricsURL: *serverMetrics,
|
||||
StartOrder: *startOrder, StartOrderSeed: *startOrderSeed,
|
||||
SessionLimit: *sessions, Duration: *duration, RecoveryDuration: *recovery, RampDuration: *ramp,
|
||||
RPCInterval: *rpcInterval, MessageInterval: *messageInterval, SampleInterval: *sampleInterval,
|
||||
RPCInterval: *rpcInterval, MessageInterval: *messageInterval, MessageRate: *messageRate,
|
||||
MessageQueueDepth: *messageQueue, DeliverySettle: *deliverySettle, SampleInterval: *sampleInterval,
|
||||
FileInterval: *fileInterval, FileSizeBytes: *fileSize, FileChunkBytes: *fileChunk, SetupTimeout: *setupTimeout,
|
||||
OperationTimeout: *operationTimeout,
|
||||
OfflineFraction: *offlineFraction, OfflineAt: *offlineAt, OfflineFor: *offlineFor,
|
||||
|
|
@ -183,6 +425,25 @@ func runSummarize(args []string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var shape struct {
|
||||
BusinessReady *int `json:"business_ready"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &shape); err != nil {
|
||||
return err
|
||||
}
|
||||
if shape.BusinessReady != nil {
|
||||
var report loadharness.StartupRunReport
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&report); err != nil {
|
||||
return err
|
||||
}
|
||||
printStartupSummary(&report)
|
||||
if !report.Pass {
|
||||
return errors.New("startup report did not pass")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var report loadharness.RunReport
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
|
@ -197,9 +458,9 @@ func runSummarize(args []string) error {
|
|||
}
|
||||
|
||||
func printSummary(report *loadharness.RunReport) {
|
||||
fmt.Fprintf(os.Stdout, "pass=%v sessions=%d peak_ready=%d reconnects=%d disconnects=%d flood_waits=%d fatal_errors=%d\n",
|
||||
fmt.Fprintf(os.Stdout, "pass=%v sessions=%d peak_ready=%d reconnects=%d disconnects=%d flood_waits=%d fatal_errors=%d scheduled=%d delivered=%d missing=%d\n",
|
||||
report.Pass, report.ExpectedSessions, report.PeakReadySessions, report.Reconnects, report.Disconnects,
|
||||
totalFloodWaits(report), report.WorkerFatalErrors)
|
||||
totalFloodWaits(report), report.WorkerFatalErrors, report.MessageScheduled, report.Delivery.Delivered, report.Delivery.Missing)
|
||||
for _, failure := range report.Failures {
|
||||
fmt.Fprintln(os.Stdout, "failure:", failure)
|
||||
}
|
||||
|
|
@ -214,12 +475,17 @@ func totalFloodWaits(report *loadharness.RunReport) uint64 {
|
|||
}
|
||||
|
||||
func usageError() error {
|
||||
return errors.New("expected one of: keygen, provision, run, summarize, help")
|
||||
return errors.New("expected one of: keygen, provision, plan-dataset, seed, snapshot, mutate-offline, startup-run, run, summarize, help")
|
||||
}
|
||||
|
||||
const usageText = `telesrv-load commands:
|
||||
keygen generate an owner-only AES-256 session key
|
||||
provision create accounts and encrypted sessions through real MTProto auth
|
||||
plan-dataset create an immutable real-data topology with stable RPC identities
|
||||
seed materialize private dialogs, supergroups and messages via real RPCs
|
||||
snapshot save paginated real dialogs and old account/channel PTS cursors
|
||||
mutate-offline create account/channel gaps while preserving the old cursors
|
||||
startup-run restore old cursors and measure dialogs/difference business readiness
|
||||
run execute sustained real-client load, offline recovery and reclamation
|
||||
summarize print the acceptance summary from a JSON report
|
||||
|
||||
|
|
|
|||
109
cmd/telesrv-update/main.go
Normal file
109
cmd/telesrv-update/main.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// Command telesrv-update serves native Telegram client update metadata and
|
||||
// immutable, range-enabled desktop update packages.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/updatecdn"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "telesrv-update:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
listenDefault := envOr("TELESRV_UPDATE_LISTEN", "127.0.0.1:2402")
|
||||
manifestDefault := envOr("TELESRV_UPDATE_MANIFEST", "data/updates/manifest.json")
|
||||
filesDefault := envOr("TELESRV_UPDATE_FILES_DIR", "data/updates/files")
|
||||
|
||||
listenAddr := flag.String("listen", listenDefault, "HTTP listen address")
|
||||
manifestPath := flag.String("manifest", manifestDefault, "release manifest path")
|
||||
filesDir := flag.String("files", filesDefault, "desktop update package directory")
|
||||
check := flag.Bool("check", false, "validate the catalog and exit")
|
||||
flag.Parse()
|
||||
|
||||
store, err := updatecdn.NewStore(*manifestPath, *filesDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load update catalog: %w", err)
|
||||
}
|
||||
if *check {
|
||||
fmt.Println("update catalog is valid")
|
||||
return nil
|
||||
}
|
||||
handler, err := updatecdn.NewHandler(store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
listener, err := net.Listen("tcp", *listenAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on %s: %w", *listenAddr, err)
|
||||
}
|
||||
|
||||
logger, err := zap.NewProduction()
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
return fmt.Errorf("initialize logger: %w", err)
|
||||
}
|
||||
defer logger.Sync() //nolint:errcheck
|
||||
|
||||
server := &http.Server{
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
MaxHeaderBytes: 32 << 10,
|
||||
}
|
||||
serveErr := make(chan error, 1)
|
||||
go func() {
|
||||
serveErr <- server.Serve(listener)
|
||||
}()
|
||||
logger.Info("update service started",
|
||||
zap.String("listen", listener.Addr().String()),
|
||||
zap.String("manifest", *manifestPath),
|
||||
zap.String("files", *filesDir))
|
||||
|
||||
stopCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("serve HTTP: %w", err)
|
||||
case <-stopCtx.Done():
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("shutdown HTTP server: %w", err)
|
||||
}
|
||||
if err := <-serveErr; err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("serve HTTP: %w", err)
|
||||
}
|
||||
logger.Info("update service stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if value, ok := os.LookupEnv(key); ok && value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
runtimemetrics "runtime/metrics"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
|
@ -60,14 +61,17 @@ 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"
|
||||
|
|
@ -77,6 +81,7 @@ import (
|
|||
"telesrv/internal/store/redisstore"
|
||||
"telesrv/internal/telegramloginhttp"
|
||||
"telesrv/internal/turnsrv"
|
||||
"telesrv/internal/updatecdn"
|
||||
"telesrv/internal/web"
|
||||
)
|
||||
|
||||
|
|
@ -271,8 +276,9 @@ func startDebugServer(ctx context.Context, addr string, metricsHandler http.Hand
|
|||
func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
|
||||
var mem runtime.MemStats
|
||||
runtime.ReadMemStats(&mem)
|
||||
return []obsmetrics.GaugeSample{
|
||||
samples := []obsmetrics.GaugeSample{
|
||||
{Name: "telesrv_go_goroutines", Value: float64(runtime.NumGoroutine())},
|
||||
{Name: "telesrv_go_scheduler_busy_seconds", Value: goSchedulerBusySeconds()},
|
||||
{Name: "telesrv_go_heap_alloc_bytes", Value: float64(mem.HeapAlloc)},
|
||||
{Name: "telesrv_go_heap_inuse_bytes", Value: float64(mem.HeapInuse)},
|
||||
{Name: "telesrv_go_heap_objects", Value: float64(mem.HeapObjects)},
|
||||
|
|
@ -281,6 +287,28 @@ func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
|
|||
{Name: "telesrv_go_gc_cycles", Value: float64(mem.NumGC)},
|
||||
{Name: "telesrv_go_gc_pause_seconds", Value: time.Duration(mem.PauseTotalNs).Seconds()},
|
||||
}
|
||||
if value, ok := processCPUSeconds(); ok {
|
||||
samples = append(samples, obsmetrics.GaugeSample{Name: "telesrv_process_cpu_seconds", Value: value})
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
// goSchedulerBusySeconds is a Go scheduler-class estimate. The runtime
|
||||
// documentation explicitly warns that CPU-class values are overestimates and
|
||||
// are not comparable to operating-system process CPU time, so capacity reports
|
||||
// use telesrv_process_cpu_seconds instead.
|
||||
func goSchedulerBusySeconds() float64 {
|
||||
samples := []runtimemetrics.Sample{
|
||||
{Name: "/cpu/classes/total:cpu-seconds"},
|
||||
{Name: "/cpu/classes/idle:cpu-seconds"},
|
||||
}
|
||||
runtimemetrics.Read(samples)
|
||||
total := samples[0].Value.Float64()
|
||||
idle := samples[1].Value.Float64()
|
||||
if total <= idle {
|
||||
return 0
|
||||
}
|
||||
return total - idle
|
||||
}
|
||||
|
||||
func mtprotoRuntimeGaugeSamples(snapshot mtprotoedge.RuntimeSnapshot) []obsmetrics.GaugeSample {
|
||||
|
|
@ -301,6 +329,15 @@ func mtprotoRuntimeGaugeSamples(snapshot mtprotoedge.RuntimeSnapshot) []obsmetri
|
|||
{Name: "telesrv_mtproto_inbound_rpc_ready_connections", Value: float64(snapshot.InboundRPCReadyConnections)},
|
||||
{Name: "telesrv_mtproto_inbound_rpc_task_limit", Value: float64(snapshot.InboundRPCMaxTasks)},
|
||||
{Name: "telesrv_mtproto_inbound_rpc_byte_limit", Value: float64(snapshot.InboundRPCMaxBytes)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_workers", Value: float64(snapshot.RPCDeliveryHookWorkers)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_capacity", Value: float64(snapshot.RPCDeliveryHookCapacity)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_reserved", Value: float64(snapshot.RPCDeliveryHookReserved)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_queued", Value: float64(snapshot.RPCDeliveryHookQueued)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_running", Value: float64(snapshot.RPCDeliveryHookRunning)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_completed_total", Value: float64(snapshot.RPCDeliveryHookCompleted)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_rejected_total", Value: float64(snapshot.RPCDeliveryHookRejected)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_panics_total", Value: float64(snapshot.RPCDeliveryHookPanics)},
|
||||
{Name: "telesrv_mtproto_rpc_delivery_hook_duration_seconds_total", Value: snapshot.RPCDeliveryHookDurationSeconds},
|
||||
{Name: "telesrv_mtproto_inbound_frame_bytes", Value: float64(snapshot.InboundFrameBytes)},
|
||||
{Name: "telesrv_mtproto_inbound_frame_byte_limit", Value: float64(snapshot.InboundFrameMaxBytes)},
|
||||
{Name: "telesrv_mtproto_outbound_tracked_bytes", Labels: []obsmetrics.Label{{Name: "kind", Value: "body"}}, Value: float64(snapshot.OutboundTrackedBytes)},
|
||||
|
|
@ -443,15 +480,20 @@ type rpcProjectionVerificationNotifier struct {
|
|||
invalidator interface {
|
||||
InvalidateRPCProjectionReadModelForUser(userID int64)
|
||||
InvalidateRPCProjectionReadModelForChannel(channelID int64)
|
||||
InvalidatePeerIdentityReadModel(domain.Peer)
|
||||
}
|
||||
users storepkg.UserCache
|
||||
log *zap.Logger
|
||||
users storepkg.UserCache
|
||||
peerIdentity bool
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func (n rpcProjectionVerificationNotifier) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
|
||||
if n.invalidator == nil {
|
||||
return nil
|
||||
}
|
||||
if n.peerIdentity {
|
||||
n.invalidator.InvalidatePeerIdentityReadModel(peer)
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
n.invalidator.InvalidateRPCProjectionReadModelForUser(peer.ID)
|
||||
|
|
@ -508,6 +550,25 @@ 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 {
|
||||
|
|
@ -568,6 +629,15 @@ func run(logger *zap.Logger) error {
|
|||
zap.Bool("schema_dirty", migrationStatus.Dirty),
|
||||
zap.Bool("schema_empty", migrationStatus.Empty),
|
||||
)
|
||||
blobRuntimeLock, err := postgres.AcquireBlobRuntimeLock(ctx, cfg.PostgresDSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire blob runtime lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := blobRuntimeLock.Close(); err != nil {
|
||||
logger.Error("release blob runtime lock", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
pool, err := postgres.Open(ctx, cfg.PostgresDSN,
|
||||
postgres.WithMaxConns(cfg.PostgresMaxConns),
|
||||
postgres.WithMinConns(cfg.PostgresMinConns),
|
||||
|
|
@ -647,7 +717,8 @@ func run(logger *zap.Logger) error {
|
|||
if cfg.TelegramLoginEnabled {
|
||||
telegramLoginHTTPHandler, err = telegramloginhttp.NewHandler(telegramloginhttp.Config{
|
||||
Service: telegramLoginService, Tokens: telegramLoginIDTokens,
|
||||
Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName,
|
||||
BotUsernames: postgres.NewUserStore(pool),
|
||||
Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName,
|
||||
Logger: logger.Named("telegram-login-http"), TrustedProxyCIDRs: cfg.TelegramLoginTrustedProxyCIDRs,
|
||||
AllowHTTP: cfg.TelegramLoginAllowHTTP,
|
||||
})
|
||||
|
|
@ -660,51 +731,157 @@ func run(logger *zap.Logger) error {
|
|||
}
|
||||
|
||||
authKeyStore := postgres.NewAuthKeyStore(pool)
|
||||
authKeyGetBatchStore, err := postgres.NewBatchedAuthKeyStore(
|
||||
authKeyStore,
|
||||
postgres.AuthKeyGetBatchConfig{
|
||||
MaxSize: cfg.AuthKeyGetBatchMax, MaxWait: cfg.AuthKeyGetBatchWait,
|
||||
QueueSize: cfg.AuthKeyGetBatchQueue, QueryTimeout: cfg.AuthKeyGetBatchTimeout,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer authKeyGetBatchStore.Close()
|
||||
authKeySessionLayerStore, err := postgres.NewBatchedAuthKeySessionLayerStore(
|
||||
authKeyStore,
|
||||
postgres.AuthKeySessionLayerBatchConfig{
|
||||
MaxSize: cfg.LayerAdvanceBatchMax, MaxWait: cfg.LayerAdvanceBatchWait,
|
||||
QueueSize: cfg.LayerAdvanceBatchQueue, QueryTimeout: cfg.LayerAdvanceBatchTimeout,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer authKeySessionLayerStore.Close()
|
||||
userStore := postgres.NewUserStore(pool)
|
||||
authzStore := postgres.NewAuthorizationStore(pool)
|
||||
adminStore := postgres.NewAdminStore(pool)
|
||||
updateStateStore := postgres.NewUpdateStateStore(pool)
|
||||
updateEventStore := postgres.NewUpdateEventStore(pool, postgres.WithUpdateEventLogger(logger.Named("store").Named("updates")))
|
||||
phoneChangeStore := postgres.NewPhoneChangeStore(pool)
|
||||
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(postgres.NewReadModelVersionStore(pool), 0, 0)
|
||||
readModelVersionBatchStore, err := storepkg.NewBatchedReadModelVersionStore(
|
||||
postgres.NewReadModelVersionStore(pool),
|
||||
storepkg.ReadModelVersionBatchConfig{
|
||||
MaxKeys: cfg.ReadModelVersionBatchMaxKeys, MaxWait: cfg.ReadModelVersionBatchWait,
|
||||
QueueSize: cfg.ReadModelVersionBatchQueue, QueryTimeout: cfg.ReadModelVersionBatchTimeout,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer readModelVersionBatchStore.Close()
|
||||
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(
|
||||
readModelVersionBatchStore,
|
||||
0,
|
||||
cfg.ReadModelVersionCacheMaxEntries,
|
||||
)
|
||||
dialogListSnapshotCache := redisstore.NewDialogListSnapshotCache(rdb, cfg.DialogListSnapshotRedisTTL)
|
||||
activeChannelIDsPageCache := redisstore.NewActiveChannelIDsPageCache(rdb, cfg.ActiveChannelIDsRedisTTL)
|
||||
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout))
|
||||
bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool)
|
||||
bootstrapUpdateStore, err := postgres.NewBatchedBootstrapUpdateJobStore(
|
||||
postgres.NewBootstrapUpdateJobStore(pool),
|
||||
postgres.BootstrapReadyBatchConfig{
|
||||
MaxSize: cfg.BootstrapReadyBatchMax, MaxWait: cfg.BootstrapReadyBatchWait,
|
||||
QueueSize: cfg.BootstrapReadyBatchQueue, QueryTimeout: cfg.BootstrapReadyBatchTimeout,
|
||||
Metrics: metricRegistry,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer bootstrapUpdateStore.Close()
|
||||
botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool)
|
||||
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
|
||||
ephemeralStore := redisstore.NewEphemeralMessageStore(rdb)
|
||||
ephemeralReportStore := postgres.NewEphemeralReportStore(pool)
|
||||
welcomeMessageStore := postgres.NewWelcomeMessageStore(pool)
|
||||
moderationReportStore := postgres.NewModerationReportStore(pool)
|
||||
authDeliveryReportStore := postgres.NewAuthDeliveryReportStore(pool)
|
||||
clientTelemetryStore := postgres.NewClientTelemetryStore(pool)
|
||||
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
|
||||
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
|
||||
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
|
||||
secretChatIDAllocator := redisstore.NewSecretChatIDAllocator(rdb, postgres.NewSecretChatIDCounterSource(pool))
|
||||
contactStore := userprojection.NewCachedContactStore(postgres.NewContactStore(pool), 0)
|
||||
reverseContactStore, err := storepkg.NewBatchedReverseContactStore(
|
||||
postgres.NewContactStore(pool),
|
||||
storepkg.ReverseContactBatchConfig{
|
||||
MaxPairs: cfg.ContactReverseBatchMaxPairs, MaxWait: cfg.ContactReverseBatchWait,
|
||||
QueueSize: cfg.ContactReverseBatchQueue, QueryTimeout: cfg.ContactReverseBatchTimeout,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reverseContactStore.Close()
|
||||
contactStore := userprojection.NewCachedContactStoreWithMaxViewers(
|
||||
reverseContactStore,
|
||||
0,
|
||||
cfg.ContactSnapshotCacheMaxViewers,
|
||||
)
|
||||
dialogStore := postgres.NewDialogStore(pool)
|
||||
chatlistStore := postgres.NewChatlistStore(pool)
|
||||
messageStore := postgres.NewMessageStore(pool,
|
||||
postgres.WithMessageAllocators(boxIDAllocator),
|
||||
postgres.WithMessageLogger(logger.Named("store").Named("messages")))
|
||||
broadcastStore := postgres.NewBroadcastStore(pool)
|
||||
broadcastService := broadcastapp.NewService(broadcastStore,
|
||||
broadcastapp.WithMessageSender(messageStore),
|
||||
broadcastapp.WithLogger(logger.Named("broadcast")))
|
||||
// 共享频道行/成员缓存 + 统一 read-model LISTEN/NOTIFY 实时失效:消除高频「逐 RPC
|
||||
// 解析频道/成员」在客户端重连同步突发里重复读同一行的放大。
|
||||
channelRowCache := postgres.NewChannelRowCache(cfg.ChannelRowCacheMaxEntries)
|
||||
channelTopMessageCache := postgres.NewChannelTopMessageCache(cfg.ChannelTopMessageCacheMaxEntries)
|
||||
channelMemberCache := postgres.NewChannelMemberCache(cfg.ChannelMemberCacheMaxEntries)
|
||||
channelDialogCache := postgres.NewChannelDialogCache(cfg.ChannelDialogCacheMaxEntries)
|
||||
channelDifferenceCache := postgres.NewChannelDifferenceBaseCache(
|
||||
cfg.ChannelDifferenceCacheMaxEntries,
|
||||
cfg.ChannelDifferenceCacheMaxBytes,
|
||||
cfg.ChannelDifferenceCacheTTL,
|
||||
)
|
||||
channelBoostCache := postgres.NewChannelBoostCache(cfg.ChannelBoostCacheMaxEntries, cfg.ChannelBoostCacheTTL)
|
||||
channelStore := postgres.NewChannelStore(pool,
|
||||
postgres.WithChannelAllocators(channelIDAllocator, channelMessageIDAllocator),
|
||||
postgres.WithChannelLogger(logger.Named("store").Named("channels")),
|
||||
postgres.WithChannelRowCache(channelRowCache),
|
||||
postgres.WithChannelTopMessageCache(channelTopMessageCache),
|
||||
postgres.WithChannelMemberCache(channelMemberCache),
|
||||
postgres.WithChannelDialogCache(channelDialogCache),
|
||||
postgres.WithChannelDifferenceBaseCache(channelDifferenceCache),
|
||||
postgres.WithChannelBoostCache(channelBoostCache))
|
||||
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator)
|
||||
activeChannelIDsPageBatcher, err := postgres.NewActiveChannelIDsPageBatcher(
|
||||
channelStore,
|
||||
postgres.ActiveChannelIDsBatchConfig{
|
||||
MaxSize: cfg.ActiveChannelIDsBatchMax, MaxWait: cfg.ActiveChannelIDsBatchWait,
|
||||
QueueSize: cfg.ActiveChannelIDsBatchQueue, QueryTimeout: cfg.ActiveChannelIDsBatchTimeout,
|
||||
Metrics: metricRegistry,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer activeChannelIDsPageBatcher.Close()
|
||||
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
|
||||
snapshot := channelDifferenceCache.Snapshot()
|
||||
return []obsmetrics.GaugeSample{
|
||||
{Name: "telesrv_channel_difference_cache_entries", Value: float64(snapshot.Entries)},
|
||||
{Name: "telesrv_channel_difference_cache_weight_bytes", Value: float64(snapshot.Weight)},
|
||||
{Name: "telesrv_channel_difference_cache_hits", Value: float64(snapshot.Hits)},
|
||||
{Name: "telesrv_channel_difference_cache_misses", Value: float64(snapshot.Misses)},
|
||||
{Name: "telesrv_channel_difference_cache_loads", Value: float64(snapshot.Loads)},
|
||||
{Name: "telesrv_channel_difference_cache_load_errors", Value: float64(snapshot.LoadErrors)},
|
||||
}
|
||||
})
|
||||
communityCatalogCache := postgres.NewCommunityCatalogCache()
|
||||
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator,
|
||||
postgres.WithCommunityCatalogCache(communityCatalogCache))
|
||||
pollStore := postgres.NewPollStore(pool)
|
||||
mediaStore := postgres.NewMediaStore(pool)
|
||||
// 头像投影缓存:所有 projector 共用一层短 TTL owner→头像缓存,消除高频「返回用户」RPC
|
||||
// 每次投影对每批 owner 固定 2 次的 CurrentProfilePhotosKind PG 查询。
|
||||
cachedPhotos := userprojection.NewCachedPhotoProvider(mediaStore, userprojection.DefaultPhotoCacheTTL)
|
||||
// 头像投影缓存:所有 projector 共用 owner→头像正/负 LRU。profile_photo NOTIFY
|
||||
// 精确失效负责正常新鲜度,长 TTL 只覆盖漏通知,避免登录 ramp 周期性重查稳定负值。
|
||||
cachedPhotos := userprojection.NewCachedPhotoProviderWithMaxEntries(
|
||||
mediaStore,
|
||||
cfg.ProfilePhotoCacheTTL,
|
||||
cfg.ProfilePhotoCacheMaxEntries,
|
||||
)
|
||||
privacyStore := privacyapp.NewCachedPrivacyStore(postgres.NewPrivacyStore(pool), 0)
|
||||
storyStore := postgres.NewStoryStore(pool)
|
||||
// Transient upload-part scratch storage always stays on local disk
|
||||
|
|
@ -774,6 +951,10 @@ 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),
|
||||
|
|
@ -828,10 +1009,25 @@ func run(logger *zap.Logger) error {
|
|||
zap.Int("blobs", stats.Blobs),
|
||||
)
|
||||
}
|
||||
if seeded, err := filesService.SeedOfficialSystemAvatar(ctx); err != nil {
|
||||
// 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 {
|
||||
return fmt.Errorf("seed official system avatar: %w", err)
|
||||
} else if seeded {
|
||||
logger.Info("official system account avatar seed import complete", zap.Int64("photo_id", domain.OfficialSystemUserPhotoID))
|
||||
} else if usingCustom {
|
||||
logger.Info("official system account avatar seed import complete (custom Server identity icon)", zap.Int64("photo_id", domain.OfficialSystemUserPhotoID))
|
||||
}
|
||||
if seeded, err := filesService.SeedBotFatherAvatar(ctx); err != nil {
|
||||
return fmt.Errorf("seed botfather avatar: %w", err)
|
||||
|
|
@ -885,11 +1081,12 @@ func run(logger *zap.Logger) error {
|
|||
Commands: adminStore,
|
||||
Restrictions: adminStore,
|
||||
})
|
||||
storageRetentionMaxAge := cfg.StorageRetentionMaxAge
|
||||
if !cfg.StorageRetentionEnable {
|
||||
storageRetentionMaxAge = 0
|
||||
}
|
||||
go maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"),
|
||||
userProjectionFacts := userprojection.NewDurableUserProjectionFacts(
|
||||
adminService,
|
||||
readModelVersionStore,
|
||||
cfg.UserProjectionFactCacheMaxEntries,
|
||||
)
|
||||
retentionWorker := maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"),
|
||||
cfg.UpdateEventRetention,
|
||||
cfg.RetentionInterval,
|
||||
cfg.RetentionBatch,
|
||||
|
|
@ -902,9 +1099,39 @@ func run(logger *zap.Logger) error {
|
|||
WithModerationRetention(moderationReportStore).
|
||||
WithUserUpdateRetention(updateEventStore).
|
||||
WithChannelUpdateRetention(channelStore).
|
||||
WithOrphanAuthKeyRetention(authKeyStore, activeSessions, cfg.OrphanAuthKeyRetention).
|
||||
WithOrphanedMediaRetention(filesService, storageRetentionMaxAge).
|
||||
Run(ctx)
|
||||
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.
|
||||
go filesapp.NewUploadPartGCWorker(filesService, logger.Named("files").Named("upload_gc"),
|
||||
cfg.UploadPartTTL,
|
||||
cfg.UploadPartGCInterval,
|
||||
|
|
@ -915,7 +1142,7 @@ func run(logger *zap.Logger) error {
|
|||
contactsService := contacts.NewService(contactStore, userStore).Configure(
|
||||
contacts.WithPhotoProvider(cachedPhotos),
|
||||
contacts.WithPrivacyEvaluator(privacyService),
|
||||
contacts.WithAccountFreezeProvider(adminService),
|
||||
contacts.WithAccountFreezeProvider(userProjectionFacts),
|
||||
contacts.WithReadModelVersions(readModelVersionStore),
|
||||
contacts.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification),
|
||||
)
|
||||
|
|
@ -1007,18 +1234,48 @@ func run(logger *zap.Logger) error {
|
|||
account.WithLoginEmailVerification(codeStore, loginEmailSender, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength))
|
||||
}
|
||||
accountService := account.NewService(passwordStore, accountOptions...)
|
||||
reservedUsernameStore := postgres.NewReservedUsernameStore(pool)
|
||||
botsService := botsapp.NewService(userStore, botStore, messageStore,
|
||||
botsapp.WithLogger(logger.Named("bots")),
|
||||
botsapp.WithBlockChecker(contactStore),
|
||||
botsapp.WithPublicChannelUsernameResolver(channelStore),
|
||||
botsapp.WithReservedUsernames(reservedUsernameStore),
|
||||
botsapp.WithUserCache(userCache),
|
||||
botsapp.WithStickerSetCreator(filesService),
|
||||
botsapp.WithGifCatalogSource(filesService),
|
||||
botsapp.WithBotAvatarStore(filesService),
|
||||
botsapp.WithUserStickerSets(accountService),
|
||||
botsapp.WithTelegramLogin(telegramLoginService),
|
||||
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 把媒体面存活
|
||||
|
|
@ -1103,7 +1360,7 @@ func run(logger *zap.Logger) error {
|
|||
// 私聊端对端加密(Secret Chat)握手状态机 + qts 投递队列(盲中继)。
|
||||
secretChatStore := postgres.NewSecretChatStore(pool)
|
||||
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
|
||||
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator)
|
||||
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore)
|
||||
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
|
||||
// 同属进程内一次性凭据,不跨实例)。
|
||||
passkeyStore := postgres.NewPasskeyStore(pool)
|
||||
|
|
@ -1112,7 +1369,7 @@ func run(logger *zap.Logger) error {
|
|||
passkeyapp.WithAllowedOrigins(cfg.PasskeyAllowedOrigins))
|
||||
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
|
||||
themeService := themesapp.NewService(postgres.NewThemeStore(pool))
|
||||
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(adminService), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification))
|
||||
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(userProjectionFacts), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), users.WithReservedUsernames(cfg.ReservedUsernames))
|
||||
privacyService.ConfigureReadModels(usersService, channelStore)
|
||||
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
|
||||
botsService.SetAIChatGenerator(aiComposeService)
|
||||
|
|
@ -1120,9 +1377,21 @@ func run(logger *zap.Logger) error {
|
|||
dialogs.WithContactStore(contactStore),
|
||||
dialogs.WithPhotoProvider(cachedPhotos),
|
||||
dialogs.WithPrivacyEvaluator(privacyService),
|
||||
dialogs.WithAccountFreezeProvider(adminService),
|
||||
dialogs.WithAccountFreezeProvider(userProjectionFacts),
|
||||
dialogs.WithPremiumChecker(usersService.PremiumActive),
|
||||
dialogs.WithReadModelVersions(readModelVersionStore),
|
||||
dialogs.WithDialogHydrationCaches(
|
||||
cfg.DialogPrivatePeerCacheMaxEntries,
|
||||
cfg.DialogPrivatePeerCacheMaxBytes,
|
||||
cfg.DialogDraftCacheMaxEntries,
|
||||
cfg.DialogDraftCacheMaxBytes,
|
||||
),
|
||||
dialogs.WithDialogListSnapshotCache(
|
||||
cfg.DialogListSnapshotCacheMaxEntries,
|
||||
cfg.DialogListSnapshotCacheMaxHeaders,
|
||||
cfg.DialogListSnapshotCacheTTL,
|
||||
),
|
||||
dialogs.WithSharedDialogListSnapshotCache(dialogListSnapshotCache),
|
||||
)
|
||||
// 编译期保证 *users.Service 满足 channel fan-out 跨 viewer 投影预热的可选能力;签名漂移会在
|
||||
// 这里立刻断编译,而非在运行时静默退化回 O(viewer) 逐 viewer 投影。
|
||||
|
|
@ -1130,10 +1399,19 @@ func run(logger *zap.Logger) error {
|
|||
channelsService := channelapp.NewService(channelStore,
|
||||
channelapp.WithBotProfileResolver(botsService),
|
||||
channelapp.WithReadModelVersions(readModelVersionStore),
|
||||
channelapp.WithActiveChannelIDsReadModel(
|
||||
activeChannelIDsPageCache,
|
||||
activeChannelIDsPageBatcher,
|
||||
cfg.ActiveChannelIDsCacheMaxEntries,
|
||||
cfg.ActiveChannelIDsCacheTTL,
|
||||
metricRegistry,
|
||||
),
|
||||
channelapp.WithSendPermissionChecker(adminService),
|
||||
channelapp.WithReservedUsernames(cfg.ReservedUsernames),
|
||||
)
|
||||
communitiesService := communitiesapp.NewService(communityStore)
|
||||
ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService)
|
||||
welcomeMessageService := welcomemessagesapp.NewService(welcomeMessageStore, channelsService)
|
||||
storiesService := storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService))
|
||||
chatlistsService := chatlistsapp.NewService(
|
||||
chatlistStore,
|
||||
|
|
@ -1146,12 +1424,23 @@ func run(logger *zap.Logger) error {
|
|||
messageapp.WithContactStore(contactStore),
|
||||
messageapp.WithPhotoProvider(cachedPhotos),
|
||||
messageapp.WithPrivacyEvaluator(privacyService),
|
||||
messageapp.WithAccountFreezeProvider(adminService),
|
||||
messageapp.WithAccountFreezeProvider(userProjectionFacts),
|
||||
messageapp.WithReadModelVersions(readModelVersionStore),
|
||||
messageapp.WithBotResponder(botsService),
|
||||
messageapp.WithSendPermissionChecker(adminService),
|
||||
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),
|
||||
|
|
@ -1173,8 +1462,10 @@ func run(logger *zap.Logger) error {
|
|||
dialogStore,
|
||||
newTranslationOptions(cfg, rateLimiter, logger)...,
|
||||
)
|
||||
authService := auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode,
|
||||
authService := auth.NewService(userStore, authzStore, codeStore, authKeyGetBatchStore, tempAuthKeyStore, cfg.DevAuthCode,
|
||||
auth.WithLoginMessages(messageStore, dialogStore),
|
||||
auth.WithLoginWelcomeMessages(identityStore, cfg.WelcomeMessagePhoneTemplate, cfg.WelcomeMessageEmailTemplate),
|
||||
auth.WithLoginCodeMessageTemplate(identityStore, cfg.LoginCodeMessageTemplate),
|
||||
auth.WithLoginCodeDelivery(messageStore),
|
||||
auth.WithPasswords(passwordStore),
|
||||
auth.WithBotLogin(botStore),
|
||||
|
|
@ -1267,6 +1558,14 @@ func run(logger *zap.Logger) error {
|
|||
logger.Info("default verifier seed complete", zap.Int64("bot_id", domain.VerifierBotUserID))
|
||||
}
|
||||
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
|
||||
var appUpdateResolver updatecdn.Resolver
|
||||
if cfg.UpdateServiceURL != "" {
|
||||
client, err := updatecdn.NewClient(cfg.UpdateServiceURL, cfg.UpdateRequestTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize update service client: %w", err)
|
||||
}
|
||||
appUpdateResolver = client
|
||||
}
|
||||
router := rpc.New(rpc.Config{
|
||||
DC: cfg.DC,
|
||||
DefaultCountryCode: cfg.DefaultCountryCode,
|
||||
|
|
@ -1286,92 +1585,109 @@ func run(logger *zap.Logger) error {
|
|||
GroupCallMaxParticipants: cfg.GroupCallMaxParticipants,
|
||||
RtmpIngestURL: cfg.LiveStreamRtmpURL,
|
||||
PublicBaseURL: cfg.PublicBaseURL,
|
||||
UpdatePublicURL: cfg.UpdatePublicURL,
|
||||
PublicAppScheme: cfg.PublicAppScheme,
|
||||
PublicAppLinkBase: cfg.PublicAppLinkBase,
|
||||
// PFS temp→perm 解析缓存:显式撤销会清缓存并断开连接,re-bind 即时失效;
|
||||
// 配置 TTL 只承担跨进程/异常失效兜底,避免大连接数周期性打满 PG。
|
||||
TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL,
|
||||
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
|
||||
TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL,
|
||||
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
|
||||
PeerIdentityCacheMaxEntries: cfg.PeerIdentityCacheMaxEntries,
|
||||
StoryActivePeerCacheMaxEntries: cfg.StoryActivePeerCacheMaxEntries,
|
||||
StoryHiddenListCacheMaxEntries: cfg.StoryHiddenListCacheMaxEntries,
|
||||
StoryHiddenListCacheMaxBytes: cfg.StoryHiddenListCacheMaxBytes,
|
||||
PresenceLastSeenBatchMax: cfg.PresenceLastSeenBatchMax,
|
||||
PresenceLastSeenBatchWait: cfg.PresenceLastSeenBatchWait,
|
||||
PresenceLastSeenBatchQueue: cfg.PresenceLastSeenBatchQueue,
|
||||
PresenceLastSeenBatchTimeout: cfg.PresenceLastSeenBatchTimeout,
|
||||
PresenceLastSeenDrainTimeout: cfg.PresenceLastSeenDrainTimeout,
|
||||
}, rpc.Deps{
|
||||
Auth: authService,
|
||||
AuthDeliveryReports: authDeliveryReportService,
|
||||
ClientTelemetry: clientTelemetryService,
|
||||
AuthKeySessionLayers: authKeyStore,
|
||||
AuthKeySessionLayers: authKeySessionLayerStore,
|
||||
ReadModelVersions: readModelVersionStore,
|
||||
UserProjectionFacts: userProjectionFacts,
|
||||
Account: accountService,
|
||||
Privacy: privacyService,
|
||||
Help: help.NewService(helpStore, helpStore,
|
||||
help.WithMapboxToken(cfg.MapboxToken),
|
||||
help.WithEmailSignupEnable(cfg.EmailSignupEnable),
|
||||
help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
|
||||
help.WithMaxUploadFileBytes(cfg.StorageMaxUploadFileBytes),
|
||||
help.WithAccountFreezeProvider(adminService),
|
||||
),
|
||||
AccountFreeze: adminService,
|
||||
AICompose: aiComposeService,
|
||||
Ephemeral: ephemeralService,
|
||||
EphemeralPush: ephemeralStore,
|
||||
Moderation: moderationService,
|
||||
Users: usersService,
|
||||
Usernames: usernamesService,
|
||||
BotVerifications: botVerificationService,
|
||||
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
|
||||
Updates: updatesService,
|
||||
BootstrapUpdates: bootstrapUpdateStore,
|
||||
BotAPIUpdates: botAPIUpdateStore,
|
||||
BotCallbacks: botCallbackStore,
|
||||
Contacts: contactsService,
|
||||
Dialogs: dialogsService,
|
||||
Chatlists: chatlistsService,
|
||||
Messages: messagesService,
|
||||
Translation: translationService,
|
||||
Channels: channelsService,
|
||||
Communities: communitiesService,
|
||||
Files: filesService,
|
||||
PremiumPromo: filesService,
|
||||
Bots: botsService,
|
||||
ServiceBotCallbacks: botsService,
|
||||
ServiceBotInlineResults: botsService,
|
||||
Polls: pollsapp.NewService(pollStore),
|
||||
Stories: storiesService,
|
||||
Phone: phoneService,
|
||||
SecretChats: secretChatService,
|
||||
Passkey: passkeyService,
|
||||
Themes: themeService,
|
||||
GroupCalls: groupCallsService,
|
||||
LiveStreams: liveStreamDep(liveStreamService),
|
||||
SFU: sfuService,
|
||||
TURN: turnService,
|
||||
LangPack: langPackService,
|
||||
Sessions: activeSessions,
|
||||
Metrics: metricRegistry,
|
||||
Inline: inlineRegistryStore,
|
||||
Limiter: rateLimiter,
|
||||
AppUpdates: appUpdateResolver,
|
||||
AccountFreeze: userProjectionFacts,
|
||||
AccountFreezeNotifications: adminService,
|
||||
AICompose: aiComposeService,
|
||||
Ephemeral: ephemeralService,
|
||||
EphemeralPush: ephemeralStore,
|
||||
WelcomeMessages: welcomeMessageService,
|
||||
Moderation: moderationService,
|
||||
Users: usersService,
|
||||
Usernames: usernamesService,
|
||||
BotVerifications: botVerificationService,
|
||||
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
|
||||
Updates: updatesService,
|
||||
BootstrapUpdates: bootstrapUpdateStore,
|
||||
BotAPIUpdates: botAPIUpdateStore,
|
||||
BotCallbacks: botCallbackStore,
|
||||
Contacts: contactsService,
|
||||
Dialogs: dialogsService,
|
||||
Chatlists: chatlistsService,
|
||||
Messages: messagesService,
|
||||
Translation: translationService,
|
||||
Channels: channelsService,
|
||||
Communities: communitiesService,
|
||||
Files: filesService,
|
||||
PremiumPromo: filesService,
|
||||
Bots: botsService,
|
||||
ServiceBotCallbacks: botsService,
|
||||
ServiceBotInlineResults: botsService,
|
||||
Polls: pollsapp.NewService(pollStore),
|
||||
Stories: storiesService,
|
||||
Phone: phoneService,
|
||||
SecretChats: secretChatService,
|
||||
Passkey: passkeyService,
|
||||
Themes: themeService,
|
||||
GroupCalls: groupCallsService,
|
||||
LiveStreams: liveStreamDep(liveStreamService),
|
||||
SFU: sfuService,
|
||||
TURN: turnService,
|
||||
LangPack: langPackService,
|
||||
Sessions: activeSessions,
|
||||
Metrics: metricRegistry,
|
||||
Inline: inlineRegistryStore,
|
||||
Limiter: rateLimiter,
|
||||
}, logger.Named("rpc"), clock.System)
|
||||
readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{
|
||||
ReadModelVersions: readModelVersionStore,
|
||||
ChannelRows: channelRowCache,
|
||||
ChannelMembers: channelMemberCache,
|
||||
ChannelDialogs: channelDialogCache,
|
||||
ChannelBoosts: channelBoostCache,
|
||||
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
|
||||
Dialogs: dialogsService,
|
||||
Privacy: privacyService,
|
||||
ProfilePhotos: cachedPhotos,
|
||||
Stories: router,
|
||||
ChannelFullBots: router,
|
||||
ChannelBotMembers: channelsService,
|
||||
ChannelMediaCounts: channelsService,
|
||||
PrivateMediaCounts: messagesService,
|
||||
RPCProjections: router,
|
||||
BaseUsers: userCache,
|
||||
BotProfiles: botsService,
|
||||
AccountSettings: router,
|
||||
ReadModelVersions: readModelVersionStore,
|
||||
ChannelRows: channelRowCache,
|
||||
ChannelTopMessages: channelTopMessageCache,
|
||||
CommunityCatalog: communityCatalogCache,
|
||||
ChannelMembers: channelMemberCache,
|
||||
ChannelDialogs: channelDialogCache,
|
||||
ChannelDifferences: channelDifferenceCache,
|
||||
ChannelBoosts: channelBoostCache,
|
||||
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
|
||||
Dialogs: dialogsService,
|
||||
Privacy: privacyService,
|
||||
ProfilePhotos: cachedPhotos,
|
||||
Stories: router,
|
||||
ChannelFullBots: router,
|
||||
ChannelBotMembers: channelsService,
|
||||
ChannelMediaCounts: channelsService,
|
||||
PrivateMediaCounts: messagesService,
|
||||
RPCProjections: router,
|
||||
PeerIdentities: router,
|
||||
BaseUsers: userCache,
|
||||
BotProfiles: botsService,
|
||||
AccountSettings: router,
|
||||
UserProjectionFacts: userProjectionFacts,
|
||||
}, logger.Named("store").Named("read-model-listener"))
|
||||
go readModelListener.Run(ctx)
|
||||
activeSessions.SetLifecycleObserver(router)
|
||||
broadcastStore := postgres.NewBroadcastStore(pool)
|
||||
broadcastService := broadcastapp.NewService(broadcastStore,
|
||||
broadcastapp.WithMessageSender(messageStore),
|
||||
broadcastapp.WithLogger(logger.Named("broadcast")))
|
||||
adminService.Configure(adminapp.Dependencies{
|
||||
Auth: authService,
|
||||
Revoker: router,
|
||||
|
|
@ -1385,10 +1701,12 @@ func run(logger *zap.Logger) error {
|
|||
Photos: filesService,
|
||||
StickerSets: filesService,
|
||||
GifCatalog: filesService,
|
||||
Storage: filesService,
|
||||
Bots: botsService,
|
||||
Emoji: filesService,
|
||||
Moderation: moderationService,
|
||||
Usernames: usernamesService,
|
||||
ReservedUsernames: reservedUsernameStore,
|
||||
Verification: verificationService,
|
||||
BotVerification: botVerificationService,
|
||||
Account: accountService,
|
||||
|
|
@ -1435,9 +1753,10 @@ func run(logger *zap.Logger) error {
|
|||
if notifier, ok := any(router).(botverificationapp.PeerNotifier); ok {
|
||||
botVerificationService.SetPeerNotifier(compositeBotVerificationNotifier{
|
||||
cache: rpcProjectionVerificationNotifier{
|
||||
invalidator: router,
|
||||
users: userCache,
|
||||
log: verificationLogger,
|
||||
invalidator: router,
|
||||
users: userCache,
|
||||
peerIdentity: true,
|
||||
log: verificationLogger,
|
||||
},
|
||||
edge: notifier,
|
||||
})
|
||||
|
|
@ -1454,9 +1773,15 @@ 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, logger.Named("broadcast").Named("delivery"),
|
||||
cfg.BroadcastWorkerInterval, cfg.BroadcastWorkerBatch).Run(ctx)
|
||||
moderationActionOptions := []moderationapp.ActionExecutorOption{}
|
||||
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),
|
||||
}
|
||||
if cfg.PublicLinkWebAddr != "" {
|
||||
moderationActionOptions = append(
|
||||
moderationActionOptions,
|
||||
|
|
@ -1485,6 +1810,7 @@ func run(logger *zap.Logger) error {
|
|||
rpc.WithOutboxUpdateBuilder(router.BuildOutboxUpdates),
|
||||
).Run(ctx)
|
||||
go rpc.NewBootstrapUpdateDispatcher(router, logger.Named("rpc").Named("bootstrap")).Run(ctx)
|
||||
go rpc.NewWelcomeDeliveryDispatcher(router, welcomeMessageStore, logger.Named("rpc").Named("welcome-delivery")).Run(ctx)
|
||||
go rpc.NewScheduledDispatcher(router, logger.Named("rpc").Named("scheduled")).Run(ctx)
|
||||
go rpc.NewSuggestedPostDispatcher(router, logger.Named("rpc").Named("suggested-post")).Run(ctx)
|
||||
go rpc.NewExpiryDispatcher(router, logger.Named("rpc").Named("expiry")).Run(ctx)
|
||||
|
|
@ -1492,6 +1818,7 @@ func run(logger *zap.Logger) error {
|
|||
go rpc.NewGroupCallSweepDispatcher(router, logger.Named("rpc").Named("groupcall-sweep"), cfg.GroupCallSweepInterval, cfg.GroupCallCheckTTL).Run(ctx)
|
||||
go router.RunChannelFanout(ctx)
|
||||
go router.RunBotAPIEnqueue(ctx)
|
||||
go router.RunPresenceLastSeenBatch(ctx)
|
||||
go router.RunPresenceSweeper(ctx, time.Minute)
|
||||
go activeSessions.RunPendingSweeper(ctx, time.Minute)
|
||||
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
|
||||
|
|
@ -1547,8 +1874,9 @@ func run(logger *zap.Logger) error {
|
|||
DC: cfg.DC,
|
||||
StrictDC: cfg.StrictDCCheck,
|
||||
RSAKey: rsaKey,
|
||||
IdentityDir: cfg.IdentityDir,
|
||||
LayerRPC: router,
|
||||
AuthKeys: authKeyStore,
|
||||
AuthKeys: authKeyGetBatchStore,
|
||||
ActiveSessions: activeSessions,
|
||||
Metrics: metricRegistry,
|
||||
ObfuscatedTCP: true,
|
||||
|
|
@ -1563,6 +1891,8 @@ func run(logger *zap.Logger) error {
|
|||
RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers,
|
||||
RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks,
|
||||
RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes,
|
||||
RPCDeliveryHookWorkers: cfg.MTProtoRPCDeliveryHookWorkers,
|
||||
RPCDeliveryHookMaxPending: cfg.MTProtoRPCDeliveryHookMaxPending,
|
||||
RPCExecutionMaxEntries: cfg.MTProtoRPCExecutionMaxEntries,
|
||||
RPCExecutionAuthMaxEntries: cfg.MTProtoRPCExecutionAuthMaxEntries,
|
||||
RPCExecutionSessionMaxEntries: cfg.MTProtoRPCExecutionSessionMaxEntries,
|
||||
|
|
@ -1581,6 +1911,23 @@ 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,10 +2,43 @@ 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 {
|
||||
|
|
|
|||
7
cmd/telesrv/process_cpu_fallback.go
Normal file
7
cmd/telesrv/process_cpu_fallback.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows
|
||||
|
||||
package main
|
||||
|
||||
func processCPUSeconds() (float64, bool) {
|
||||
return 0, false
|
||||
}
|
||||
12
cmd/telesrv/process_cpu_test.go
Normal file
12
cmd/telesrv/process_cpu_test.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || windows
|
||||
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestProcessCPUSecondsAvailable(t *testing.T) {
|
||||
seconds, ok := processCPUSeconds()
|
||||
if !ok || seconds < 0 {
|
||||
t.Fatalf("process CPU seconds = %v, available=%v", seconds, ok)
|
||||
}
|
||||
}
|
||||
17
cmd/telesrv/process_cpu_unix.go
Normal file
17
cmd/telesrv/process_cpu_unix.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
|
||||
|
||||
package main
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
func processCPUSeconds() (float64, bool) {
|
||||
var usage unix.Rusage
|
||||
if err := unix.Getrusage(unix.RUSAGE_SELF, &usage); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
ns := unix.TimevalToNsec(usage.Utime) + unix.TimevalToNsec(usage.Stime)
|
||||
if ns < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return float64(ns) / 1e9, true
|
||||
}
|
||||
21
cmd/telesrv/process_cpu_windows.go
Normal file
21
cmd/telesrv/process_cpu_windows.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
func processCPUSeconds() (float64, bool) {
|
||||
handle, err := windows.GetCurrentProcess()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
var creation, exit, kernel, user windows.Filetime
|
||||
if err := windows.GetProcessTimes(handle, &creation, &exit, &kernel, &user); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
ns := kernel.Nanoseconds() + user.Nanoseconds()
|
||||
if ns < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return float64(ns) / 1e9, true
|
||||
}
|
||||
|
|
@ -70,7 +70,12 @@ services:
|
|||
# 桶由 telesrv 自己在启动时按需创建(internal/app/files/blobs3.go 的 NewS3FS),
|
||||
# 这里不需要额外的 mc 初始化容器。控制台(9001)仅用于本地调试查看已存对象。
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
# 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
|
||||
container_name: ${TELESRV_DOCKER_PREFIX:-owpengram}-minio
|
||||
command: ["server", "/data", "--console-address", ":9001"]
|
||||
environment:
|
||||
|
|
|
|||
100
deploy/docker/.env.example
Normal file
100
deploy/docker/.env.example
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# Generated automatically by scripts/new-docker-env.*. Keep the resulting .env
|
||||
# private and never commit it.
|
||||
|
||||
COMPOSE_PROJECT_NAME=gramsrv-main
|
||||
TELESRV_DEPLOYMENT_PROFILE=main-monolith-v1
|
||||
TELESRV_IMAGE_PREFIX=ghcr.io/iamxvbaba/gramsrv
|
||||
TELESRV_IMAGE_TAG=main
|
||||
TELESRV_SERVER_BUILD_TARGET=server-test
|
||||
TELESRV_LOG_LEVEL=info
|
||||
|
||||
# Build provenance for local builds.
|
||||
TELESRV_BUILD_COMMIT=unknown
|
||||
TELESRV_BUILD_BRANCH=main
|
||||
TELESRV_BUILD_TREE_STATE=unknown
|
||||
TELESRV_BUILD_DATE=unknown
|
||||
|
||||
# The host-network server and admin reach these dependencies through loopback.
|
||||
POSTGRES_DB=telesrv_main
|
||||
POSTGRES_USER=telesrv
|
||||
POSTGRES_PASSWORD=CHANGEME
|
||||
TELESRV_POSTGRES_HOST_PORT=15432
|
||||
TELESRV_POSTGRES_DSN=postgres://telesrv:CHANGEME@127.0.0.1:15432/telesrv_main?sslmode=disable
|
||||
TELESRV_REDIS_HOST_PORT=16379
|
||||
TELESRV_REDIS_ADDR=127.0.0.1:16379
|
||||
TELESRV_REDIS_PASSWORD=CHANGEME
|
||||
|
||||
# Independent random values generated for each deployment.
|
||||
TELESRV_ADMIN_API_TOKEN=CHANGEME
|
||||
TELESRV_ADMIN_UI_PASSWORD=CHANGEME
|
||||
TELESRV_ADMIN_UI_TOKEN=
|
||||
TELESRV_ADMIN_SESSION_KEY=CHANGEME
|
||||
TELESRV_TURN_SECRET=CHANGEME
|
||||
TELESRV_OTP_WEBHOOK_SECRET=CHANGEME
|
||||
|
||||
# Development delivery is accepted automatically on loopback. Internet-facing
|
||||
# deployments must explicitly opt in or replace it with the webhook provider.
|
||||
TELESRV_DEV_AUTH_CODE=12345
|
||||
TELESRV_PHONE_CODE_DELIVERY_PROVIDER=development
|
||||
TELESRV_ALLOW_INSECURE_DEVELOPMENT_AUTH=false
|
||||
TELESRV_OTP_WEBHOOK_URL=
|
||||
TELESRV_OTP_WEBHOOK_TIMEOUT=5s
|
||||
|
||||
# The published main test image deliberately contains a public test RSA key so
|
||||
# fresh test clients share one fingerprint. Existing server_state is preserved.
|
||||
# Build target "server" plus mode "generated" for a private deployment identity.
|
||||
TELESRV_RSA_IDENTITY_MODE=test
|
||||
|
||||
# Must be a client-reachable IP address, never a DNS name.
|
||||
TELESRV_ADVERTISE_IP=CHANGEME
|
||||
TELESRV_SERVER_PORT=2398
|
||||
TELESRV_DEFAULT_COUNTRY_CODE=CN
|
||||
TELESRV_PUBLIC_BASE_URL=CHANGEME
|
||||
TELESRV_PUBLIC_APP_SCHEME=telesrv
|
||||
TELESRV_PUBLIC_WEB_BASE_URL=CHANGEME
|
||||
TELESRV_PUBLIC_LINK_PORT=2401
|
||||
|
||||
# The monolith owns SFU, TURN, and RTMP. Host mode avoids one Docker mapping per
|
||||
# TURN relay port. Bridge mode publishes a bounded 64-port compatibility range.
|
||||
TELESRV_SERVER_HOST_NETWORK=true
|
||||
TELESRV_SFU_ENABLE=true
|
||||
TELESRV_SFU_UDP_PORT=12399
|
||||
TELESRV_SFU_ADVERTISE_IP=CHANGEME
|
||||
TELESRV_TURN_ENABLE=true
|
||||
TELESRV_TURN_UDP_PORT=12400
|
||||
TELESRV_TURN_ADVERTISE_IP=CHANGEME
|
||||
TELESRV_TURN_RELAY_MIN_PORT=12500
|
||||
TELESRV_TURN_RELAY_MAX_PORT=12999
|
||||
TELESRV_TURN_BRIDGE_RELAY_MAX_PORT=12563
|
||||
TELESRV_CALL_TURN_CREDENTIAL_TTL=6h
|
||||
TELESRV_CALL_FORCE_RELAY=false
|
||||
TELESRV_LIVESTREAM_ENABLE=true
|
||||
TELESRV_LIVESTREAM_RTMP_PORT=2400
|
||||
TELESRV_LIVESTREAM_RTMP_URL=CHANGEME
|
||||
|
||||
# Direct host listeners and their health-probe addresses.
|
||||
TELESRV_PUBLIC_BIND_IP=127.0.0.1
|
||||
TELESRV_PUBLIC_LISTEN_HOST=127.0.0.1
|
||||
TELESRV_LOCAL_BIND_IP=127.0.0.1
|
||||
TELESRV_LOCAL_LISTEN_HOST=127.0.0.1
|
||||
TELESRV_SERVER_HEALTH_IP=127.0.0.1
|
||||
TELESRV_SERVER_HEALTH_URL_HOST=127.0.0.1
|
||||
TELESRV_ADMIN_API_PORT=2599
|
||||
TELESRV_ADMIN_BIND_IP=127.0.0.1
|
||||
TELESRV_ADMIN_LISTEN_HOST=127.0.0.1
|
||||
TELESRV_ADMIN_HEALTH_IP=127.0.0.1
|
||||
TELESRV_ADMIN_PORT=2600
|
||||
|
||||
# Durable media state. Switching an existing deployment between localfs and S3
|
||||
# requires the explicit blob migration procedure.
|
||||
TELESRV_BLOB_BACKEND=localfs
|
||||
TELESRV_EXTERNAL_MEDIA_ENABLE=true
|
||||
TELESRV_WEBPAGE_PREVIEW_ENABLE=true
|
||||
TELESRV_S3_ENDPOINT=
|
||||
TELESRV_S3_REGION=
|
||||
TELESRV_S3_BUCKET=
|
||||
TELESRV_S3_ACCESS_KEY_ID=
|
||||
TELESRV_S3_SECRET_ACCESS_KEY=
|
||||
TELESRV_S3_USE_SSL=true
|
||||
TELESRV_S3_PATH_STYLE=false
|
||||
TELESRV_S3_CREATE_BUCKET=false
|
||||
1
deploy/docker/assets/test-server-rsa.pem.b64
Normal file
1
deploy/docker/assets/test-server-rsa.pem.b64
Normal file
|
|
@ -0,0 +1 @@
|
|||
LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBeEYvLzBNMCsvNVB6Z2ROYWdUWCtKK2RKZ3I3NVpDVHVpRzhpNHg3WXdtSkYramlPCkdDam03WDdCTENhTWMxK2hPWllETDMrR3ZsZS9BS3lrVzFxb3VhQ0pNVngvSCsybDhMRlhMZWxaMlBMYXdUYjgKQTdCbFRxV3pMM2RiNUJ1Z01OV3ppTDlUdWhSOEluMWJ3S1kwN1FWcFI5aW41empBc0FHTEJrK21HdDBEblZ5TQpmMVhvcDJsTENGTm1tMEY0eWtjQWVhTENDSVBiR1dkZGxpTFk4eEVFaEk0R08ybDFVM2taTXdJT2RPbkFHSkZ0CmdVQW9UZStGSFI2RjFzOWFkQ1ZaQjF0ZUwvaGY5UitXbWVrSnd5Z1Z6ME1ZRUg3eTZVNDlUNDUrL1c3T0Y2WDYKZzBXMGoxdVNTcnNZNHFON3R3eGJUYWQ5emRHWjd5cys5ditQdVFJREFRQUJBb0lCQUFqdXFPOHhkczBmU0xNKwpEdDdUdXVUTHcyODhDcElBa0EwS3FSYVZuNXh2NWVqMHk1blR1blZSRDY1WGJvb003b04xREY0THVmQk1nM2FmClk3WjRFRGFwVTdRNEZkdzQ3aFJkcks1ODc4WkxmYUhPUTNaVGZyZ3VGMUZ3WjNDZnhSQ1RsOS8vZStwNTVnK1gKamlYY0tZb2lkZUI3dlY5cUdIR3BFRTdRTHFrSUVpbk1FV05hQjh1dGN0SDdUWGRXYTRweWZJR2lQckhNTjJ6SApoRjQwSWI3bkpBNmtodHpzTkNEU0Q5WG5ibEVORW9kMUU1Z1JzalE5ZkdzaGRCdHBEc1hyTEJGTDdLTUREb1FtCmN6cnQvS3hsWk1wYnRPZno2dWE1ZUtFQkJUdE51dG1WY3AxcTl5K2NRcFBaem5ZaVJUTCtPSnpWZ2Z1SE9PLzAKZWEyai93RUNnWUVBNkk2ZitFTG84QVA3UTRXMHFzc3VYS3ZIRUYvUkVFZngyTHVlaDBsclVoVlIzSWdqYWtGYwpiVFJsTEVRSzhRTmJEQW1OTXVIckVabmptR3U3bnkzek45NHNsQy95Y0k1cGQ5aFFHVndPMjJlcjlvMitNOXcyCkpwQjRRZjhjOFNLL3BIUU45K2pPdEJ4VjJkcmJmZTBvVW9LRXRZeUQ1Y2J0akZUM3lmZUEyQ0VDZ1lFQTJDdW8KUzg0MWtWcHB4MUt3cEw0aTFmZ0dRUHlrYUtyRDRvR1pyWWd2MkZ4VzU1aS9xNUZjWUw2ZkUzN0phTTFwby9SQgp0bkhnMzVOYW5nL3l1Wlh6NkViNEswQ3lMKzhMdWhDSWI2UHhEdnF5Q1hia1hUd0hTVlJBbkdFTVNOM3phLzdGCkZDSkYxQUJXZzhqbEtSZEEvbFJ3cW84UDhaZ0JlQk1xcEtaaDVKa0NnWUJoQ0ltazI3NDN6MkY2dGdKQk5VL2QKNk9yQllVbHBJcXU5ZytOTWpZelREZ1EvSVNxdHZpSGppdllmOXpBZGlnbm1SdUg4ZGhsUUdjYkdKVVYrMEh4bwpOaktoampQNVZPS2ExODNzRnVZNEU5VERwamJUaXJHcGU2UkIzVUZsTjl1QXNjL1dQZlJwWUYxTjdpeWhLV0FtCnRVRE1RNW9ST09TTEpqVFJ0NHl5SVFLQmdRQ1RmeTVsRXYyd0FQMzk5K2o1YjVhN1luRjU5Q2lHRmtaMERiUDcKR05wMGlZVHVuMlhndmQxSFVhbWZGcnA4blBRQTM4L2FtZGN6RmdzVm9KSWdtVFdFZnJBa2F3OXA3M1NUNzJYNApydWJ6THBFK0xmWmh1MnpKVndpQzZ5RURzeFc5MFdkTmRwa29yMVpZczBIUmlNRmJCK2ljSitOY0dEaWdZb3VOCkxzM0t1UUtCZ1FDVEF4Vk03ek5QZ3NmZnVkWlA3Rk1ueTIwTE1SNitGUW83eFJ6NUVsWWFDVmdYTTFOSDRLL3IKeUZrT0NHaE9ONkkvTVVOQlB4V0xFdVpCUmZ5cmx5M2JwM2o3UjYvaExYNGZOeXc5QjNHUXVWeUNIRDJENHRvZAo2SjVWby9Fc2VxcGVlS2E2Q3YvWTJIVkIwa2ZHdzFWQ0MvZ00wMUw4dWVkM2hzcjJMRDJrQ3c9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=
|
||||
8
deploy/docker/assets/test-server-rsa.pub
Normal file
8
deploy/docker/assets/test-server-rsa.pub
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
-----BEGIN RSA PUBLIC KEY-----
|
||||
MIIBCgKCAQEAxF//0M0+/5PzgdNagTX+J+dJgr75ZCTuiG8i4x7YwmJF+jiOGCjm
|
||||
7X7BLCaMc1+hOZYDL3+Gvle/AKykW1qouaCJMVx/H+2l8LFXLelZ2PLawTb8A7Bl
|
||||
TqWzL3db5BugMNWziL9TuhR8In1bwKY07QVpR9in5zjAsAGLBk+mGt0DnVyMf1Xo
|
||||
p2lLCFNmm0F4ykcAeaLCCIPbGWddliLY8xEEhI4GO2l1U3kZMwIOdOnAGJFtgUAo
|
||||
Te+FHR6F1s9adCVZB1teL/hf9R+WmekJwygVz0MYEH7y6U49T45+/W7OF6X6g0W0
|
||||
j1uSSrsY4qN7twxbTad9zdGZ7ys+9v+PuQIDAQAB
|
||||
-----END RSA PUBLIC KEY-----
|
||||
63
deploy/docker/compose.bridge-network.yaml
Normal file
63
deploy/docker/compose.bridge-network.yaml
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
services:
|
||||
server:
|
||||
# Compatibility fallback for hosts without network_mode: host. The bounded
|
||||
# TURN relay range is published 1:1 to avoid address translation mismatch.
|
||||
network_mode: !reset null
|
||||
environment:
|
||||
TELESRV_LISTEN: 0.0.0.0:${TELESRV_SERVER_PORT:-2398}
|
||||
TELESRV_PUBLIC_LINK_WEB_ADDR: 0.0.0.0:${TELESRV_PUBLIC_LINK_PORT:-2401}
|
||||
TELESRV_POSTGRES_DSN: postgres://${POSTGRES_USER:-telesrv}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-telesrv_main}?sslmode=disable
|
||||
TELESRV_REDIS_ADDR: redis:6379
|
||||
TELESRV_ADMIN_API_ADDR: 0.0.0.0:${TELESRV_ADMIN_API_PORT:-2599}
|
||||
TELESRV_TURN_RELAY_MAX_PORT: ${TELESRV_TURN_BRIDGE_RELAY_MAX_PORT:-12563}
|
||||
TELESRV_SERVER_HEALTH_IP: 127.0.0.1
|
||||
TELESRV_SERVER_HEALTH_URL_HOST: 127.0.0.1
|
||||
ports:
|
||||
- name: mtproto
|
||||
target: ${TELESRV_SERVER_PORT:-2398}
|
||||
published: "${TELESRV_SERVER_PORT:-2398}"
|
||||
host_ip: ${TELESRV_PUBLIC_BIND_IP:-127.0.0.1}
|
||||
protocol: tcp
|
||||
- name: public-links
|
||||
target: ${TELESRV_PUBLIC_LINK_PORT:-2401}
|
||||
published: "${TELESRV_PUBLIC_LINK_PORT:-2401}"
|
||||
host_ip: ${TELESRV_LOCAL_BIND_IP:-127.0.0.1}
|
||||
protocol: tcp
|
||||
- name: rtmp-ingest
|
||||
target: ${TELESRV_LIVESTREAM_RTMP_PORT:-2400}
|
||||
published: "${TELESRV_LIVESTREAM_RTMP_PORT:-2400}"
|
||||
host_ip: ${TELESRV_PUBLIC_BIND_IP:-127.0.0.1}
|
||||
protocol: tcp
|
||||
- name: sfu-media
|
||||
target: ${TELESRV_SFU_UDP_PORT:-12399}
|
||||
published: "${TELESRV_SFU_UDP_PORT:-12399}"
|
||||
host_ip: ${TELESRV_PUBLIC_BIND_IP:-127.0.0.1}
|
||||
protocol: udp
|
||||
- name: turn
|
||||
target: ${TELESRV_TURN_UDP_PORT:-12400}
|
||||
published: "${TELESRV_TURN_UDP_PORT:-12400}"
|
||||
host_ip: ${TELESRV_PUBLIC_BIND_IP:-127.0.0.1}
|
||||
protocol: udp
|
||||
- "${TELESRV_PUBLIC_BIND_IP:-127.0.0.1}:${TELESRV_TURN_RELAY_MIN_PORT:-12500}-${TELESRV_TURN_BRIDGE_RELAY_MAX_PORT:-12563}:${TELESRV_TURN_RELAY_MIN_PORT:-12500}-${TELESRV_TURN_BRIDGE_RELAY_MAX_PORT:-12563}/udp"
|
||||
networks:
|
||||
- data
|
||||
- control
|
||||
- outbound
|
||||
|
||||
admin:
|
||||
network_mode: !reset null
|
||||
environment:
|
||||
TELESRV_POSTGRES_DSN: postgres://${POSTGRES_USER:-telesrv}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-telesrv_main}?sslmode=disable
|
||||
TELESRV_ADMIN_API_ADDR: server:${TELESRV_ADMIN_API_PORT:-2599}
|
||||
TELESRV_ADMIN_UI_ADDR: 0.0.0.0:${TELESRV_ADMIN_PORT:-2600}
|
||||
TELESRV_ADMIN_HEALTH_IP: 127.0.0.1
|
||||
ports:
|
||||
- name: admin-ui
|
||||
target: ${TELESRV_ADMIN_PORT:-2600}
|
||||
published: "${TELESRV_ADMIN_PORT:-2600}"
|
||||
host_ip: ${TELESRV_ADMIN_BIND_IP:-127.0.0.1}
|
||||
protocol: tcp
|
||||
networks:
|
||||
- data
|
||||
- control
|
||||
- admin_host_access
|
||||
228
deploy/docker/compose.yaml
Normal file
228
deploy/docker/compose.yaml
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
name: ${COMPOSE_PROJECT_NAME:-gramsrv-main}
|
||||
|
||||
x-build-args: &build-args
|
||||
VCS_REF: ${TELESRV_BUILD_COMMIT:-unknown}
|
||||
VCS_BRANCH: ${TELESRV_BUILD_BRANCH:-main}
|
||||
VCS_TREE_STATE: ${TELESRV_BUILD_TREE_STATE:-unknown}
|
||||
BUILD_DATE: ${TELESRV_BUILD_DATE:-unknown}
|
||||
|
||||
x-app: &app
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
user: "10001:10001"
|
||||
cap_drop:
|
||||
- ALL
|
||||
pids_limit: 1024
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,nodev,size=128m
|
||||
stop_grace_period: 1m
|
||||
logging: &app-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: "5"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine@sha256:979c4379dd698aba0b890599a6104e082035f98ef31d9b9291ec22f2b13059ca
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-telesrv_main}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-telesrv}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/docker/.env}
|
||||
POSTGRES_INITDB_ARGS: --data-checksums
|
||||
TZ: UTC
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- max_locks_per_transaction=512
|
||||
- -c
|
||||
- shared_preload_libraries=pg_stat_statements
|
||||
- -c
|
||||
- pg_stat_statements.track=all
|
||||
- -c
|
||||
- track_io_timing=on
|
||||
shm_size: 256mb
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ../postgres-init:/docker-entrypoint-initdb.d:ro
|
||||
ports:
|
||||
- name: server-host-postgres
|
||||
target: 5432
|
||||
published: "${TELESRV_POSTGRES_HOST_PORT:-15432}"
|
||||
host_ip: 127.0.0.1
|
||||
protocol: tcp
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 1m
|
||||
logging: *app-logging
|
||||
networks:
|
||||
- data
|
||||
- server_host_access
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine@sha256:8b81dd37ff027bec4e516d41acfbe9fe2460070dc6d4a4570a2ac5b9d59df065
|
||||
environment:
|
||||
REDIS_PASSWORD: ${TELESRV_REDIS_PASSWORD:?set TELESRV_REDIS_PASSWORD in deploy/docker/.env}
|
||||
command:
|
||||
- sh
|
||||
- -ec
|
||||
- exec redis-server --appendonly yes --appendfsync everysec --requirepass "$$REDIS_PASSWORD"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- name: server-host-redis
|
||||
target: 6379
|
||||
published: "${TELESRV_REDIS_HOST_PORT:-16379}"
|
||||
host_ip: 127.0.0.1
|
||||
protocol: tcp
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "redis-cli --no-auth-warning -a \"$$REDIS_PASSWORD\" ping | grep -qx PONG"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 5s
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
logging: *app-logging
|
||||
networks:
|
||||
- data
|
||||
- server_host_access
|
||||
|
||||
server:
|
||||
<<: *app
|
||||
image: ${TELESRV_IMAGE_PREFIX:-ghcr.io/iamxvbaba/gramsrv}/server:${TELESRV_IMAGE_TAG:-main}
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: Dockerfile
|
||||
target: ${TELESRV_SERVER_BUILD_TARGET:-server-test}
|
||||
args: *build-args
|
||||
environment:
|
||||
TELESRV_LOG_LEVEL: ${TELESRV_LOG_LEVEL:-info}
|
||||
TELESRV_LISTEN: ${TELESRV_PUBLIC_LISTEN_HOST:-127.0.0.1}:${TELESRV_SERVER_PORT:-2398}
|
||||
TELESRV_ADVERTISE_IP: ${TELESRV_ADVERTISE_IP:?set TELESRV_ADVERTISE_IP in deploy/docker/.env}
|
||||
TELESRV_DEFAULT_COUNTRY_CODE: ${TELESRV_DEFAULT_COUNTRY_CODE:-CN}
|
||||
TELESRV_PUBLIC_BASE_URL: ${TELESRV_PUBLIC_BASE_URL:?set TELESRV_PUBLIC_BASE_URL in deploy/docker/.env}
|
||||
TELESRV_PUBLIC_APP_SCHEME: ${TELESRV_PUBLIC_APP_SCHEME:-telesrv}
|
||||
TELESRV_PUBLIC_WEB_BASE_URL: ${TELESRV_PUBLIC_WEB_BASE_URL:?set TELESRV_PUBLIC_WEB_BASE_URL in deploy/docker/.env}
|
||||
TELESRV_PUBLIC_LINK_WEB_ADDR: ${TELESRV_LOCAL_LISTEN_HOST:-127.0.0.1}:${TELESRV_PUBLIC_LINK_PORT:-2401}
|
||||
TELESRV_POSTGRES_DSN: ${TELESRV_POSTGRES_DSN:?set TELESRV_POSTGRES_DSN in deploy/docker/.env}
|
||||
TELESRV_REDIS_ADDR: ${TELESRV_REDIS_ADDR:-127.0.0.1:16379}
|
||||
TELESRV_REDIS_PASSWORD: ${TELESRV_REDIS_PASSWORD:?set TELESRV_REDIS_PASSWORD in deploy/docker/.env}
|
||||
TELESRV_REDIS_DB: 0
|
||||
TELESRV_ADMIN_API_ADDR: 127.0.0.1:${TELESRV_ADMIN_API_PORT:-2599}
|
||||
TELESRV_ADMIN_API_TOKEN: ${TELESRV_ADMIN_API_TOKEN:?set TELESRV_ADMIN_API_TOKEN in deploy/docker/.env}
|
||||
TELESRV_DEV_AUTH_CODE: ${TELESRV_DEV_AUTH_CODE:-12345}
|
||||
TELESRV_PHONE_CODE_DELIVERY_PROVIDER: ${TELESRV_PHONE_CODE_DELIVERY_PROVIDER:-development}
|
||||
TELESRV_OTP_WEBHOOK_URL: ${TELESRV_OTP_WEBHOOK_URL:-}
|
||||
TELESRV_OTP_WEBHOOK_SECRET: ${TELESRV_OTP_WEBHOOK_SECRET:-}
|
||||
TELESRV_OTP_WEBHOOK_TIMEOUT: ${TELESRV_OTP_WEBHOOK_TIMEOUT:-5s}
|
||||
TELESRV_RSA_KEY: /var/lib/telesrv/server_rsa.pem
|
||||
TELESRV_RSA_IDENTITY_MODE: ${TELESRV_RSA_IDENTITY_MODE:-test}
|
||||
TELESRV_SFU_ENABLE: ${TELESRV_SFU_ENABLE:-true}
|
||||
TELESRV_SFU_UDP_PORT: ${TELESRV_SFU_UDP_PORT:-12399}
|
||||
TELESRV_SFU_ADVERTISE_IP: ${TELESRV_SFU_ADVERTISE_IP:?set TELESRV_SFU_ADVERTISE_IP in deploy/docker/.env}
|
||||
TELESRV_TURN_ENABLE: ${TELESRV_TURN_ENABLE:-true}
|
||||
TELESRV_TURN_UDP_PORT: ${TELESRV_TURN_UDP_PORT:-12400}
|
||||
TELESRV_TURN_ADVERTISE_IP: ${TELESRV_TURN_ADVERTISE_IP:?set TELESRV_TURN_ADVERTISE_IP in deploy/docker/.env}
|
||||
TELESRV_TURN_SECRET: ${TELESRV_TURN_SECRET:?set TELESRV_TURN_SECRET in deploy/docker/.env}
|
||||
TELESRV_TURN_RELAY_MIN_PORT: ${TELESRV_TURN_RELAY_MIN_PORT:-12500}
|
||||
TELESRV_TURN_RELAY_MAX_PORT: ${TELESRV_TURN_RELAY_MAX_PORT:-12999}
|
||||
TELESRV_CALL_TURN_CREDENTIAL_TTL: ${TELESRV_CALL_TURN_CREDENTIAL_TTL:-6h}
|
||||
TELESRV_CALL_FORCE_RELAY: ${TELESRV_CALL_FORCE_RELAY:-false}
|
||||
TELESRV_LIVESTREAM_ENABLE: ${TELESRV_LIVESTREAM_ENABLE:-true}
|
||||
TELESRV_LIVESTREAM_RTMP_ADDR: :${TELESRV_LIVESTREAM_RTMP_PORT:-2400}
|
||||
TELESRV_LIVESTREAM_RTMP_URL: ${TELESRV_LIVESTREAM_RTMP_URL:?set TELESRV_LIVESTREAM_RTMP_URL in deploy/docker/.env}
|
||||
TELESRV_LIVESTREAM_WORK_DIR: /var/lib/telesrv/livestream
|
||||
TELESRV_LANGPACK_SEED_DIR: /usr/share/telesrv/langpack
|
||||
TELESRV_BLOB_BACKEND: ${TELESRV_BLOB_BACKEND:-localfs}
|
||||
TELESRV_BLOB_DIR: /var/lib/telesrv/blobs
|
||||
TELESRV_BLOB_STAGING_DIR: /var/lib/telesrv/blob-staging
|
||||
TELESRV_MAPTILE_CACHE_DIR: /var/lib/telesrv/maptiles
|
||||
TELESRV_EXTERNAL_MEDIA_ENABLE: ${TELESRV_EXTERNAL_MEDIA_ENABLE:-true}
|
||||
TELESRV_WEBPAGE_PREVIEW_ENABLE: ${TELESRV_WEBPAGE_PREVIEW_ENABLE:-true}
|
||||
TELESRV_S3_ENDPOINT: ${TELESRV_S3_ENDPOINT:-}
|
||||
TELESRV_S3_REGION: ${TELESRV_S3_REGION:-}
|
||||
TELESRV_S3_BUCKET: ${TELESRV_S3_BUCKET:-}
|
||||
TELESRV_S3_ACCESS_KEY_ID: ${TELESRV_S3_ACCESS_KEY_ID:-}
|
||||
TELESRV_S3_SECRET_ACCESS_KEY: ${TELESRV_S3_SECRET_ACCESS_KEY:-}
|
||||
TELESRV_S3_USE_SSL: ${TELESRV_S3_USE_SSL:-true}
|
||||
TELESRV_S3_PATH_STYLE: ${TELESRV_S3_PATH_STYLE:-false}
|
||||
TELESRV_S3_CREATE_BUCKET: ${TELESRV_S3_CREATE_BUCKET:-false}
|
||||
TELESRV_SERVER_PORT: ${TELESRV_SERVER_PORT:-2398}
|
||||
TELESRV_PUBLIC_LINK_PORT: ${TELESRV_PUBLIC_LINK_PORT:-2401}
|
||||
TELESRV_SERVER_HEALTH_IP: ${TELESRV_SERVER_HEALTH_IP:-127.0.0.1}
|
||||
TELESRV_SERVER_HEALTH_URL_HOST: ${TELESRV_SERVER_HEALTH_URL_HOST:-127.0.0.1}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- server_state:/var/lib/telesrv
|
||||
network_mode: host
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- >-
|
||||
wget -q -O /dev/null "http://$${TELESRV_SERVER_HEALTH_URL_HOST}:$${TELESRV_PUBLIC_LINK_PORT}/healthz"
|
||||
&& nc -z "$${TELESRV_SERVER_HEALTH_IP}" "$${TELESRV_SERVER_PORT}"
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 114
|
||||
start_period: 30s
|
||||
|
||||
admin:
|
||||
<<: *app
|
||||
image: ${TELESRV_IMAGE_PREFIX:-ghcr.io/iamxvbaba/gramsrv}/admin:${TELESRV_IMAGE_TAG:-main}
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: Dockerfile
|
||||
target: admin
|
||||
args: *build-args
|
||||
environment:
|
||||
TELESRV_POSTGRES_DSN: ${TELESRV_POSTGRES_DSN:?set TELESRV_POSTGRES_DSN in deploy/docker/.env}
|
||||
TELESRV_ADMIN_API_ADDR: 127.0.0.1:${TELESRV_ADMIN_API_PORT:-2599}
|
||||
TELESRV_ADMIN_API_TOKEN: ${TELESRV_ADMIN_API_TOKEN:?set TELESRV_ADMIN_API_TOKEN in deploy/docker/.env}
|
||||
TELESRV_ADMIN_UI_ADDR: ${TELESRV_ADMIN_LISTEN_HOST:-127.0.0.1}:${TELESRV_ADMIN_PORT:-2600}
|
||||
TELESRV_ADMIN_UI_PASSWORD: ${TELESRV_ADMIN_UI_PASSWORD:?set TELESRV_ADMIN_UI_PASSWORD in deploy/docker/.env}
|
||||
TELESRV_ADMIN_UI_TOKEN: ${TELESRV_ADMIN_UI_TOKEN:-}
|
||||
TELESRV_ADMIN_SESSION_KEY: ${TELESRV_ADMIN_SESSION_KEY:?set TELESRV_ADMIN_SESSION_KEY in deploy/docker/.env}
|
||||
TELESRV_BLOB_BACKEND: ${TELESRV_BLOB_BACKEND:-localfs}
|
||||
TELESRV_BLOB_DIR: /var/lib/telesrv/blobs
|
||||
TELESRV_BLOB_STAGING_DIR: /var/lib/telesrv/blob-staging
|
||||
TELESRV_ADMIN_HEALTH_IP: ${TELESRV_ADMIN_HEALTH_IP:-127.0.0.1}
|
||||
TELESRV_ADMIN_PORT: ${TELESRV_ADMIN_PORT:-2600}
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- server_state:/var/lib/telesrv:ro
|
||||
network_mode: host
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z \"$$TELESRV_ADMIN_HEALTH_IP\" \"$$TELESRV_ADMIN_PORT\""]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 5s
|
||||
|
||||
networks:
|
||||
data:
|
||||
internal: true
|
||||
server_host_access:
|
||||
admin_host_access:
|
||||
control:
|
||||
internal: true
|
||||
outbound:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
server_state:
|
||||
99
deploy/docker/docker-entrypoint.sh
Executable file
99
deploy/docker/docker-entrypoint.sh
Executable file
|
|
@ -0,0 +1,99 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
umask 077
|
||||
|
||||
command_name="${1##*/}"
|
||||
|
||||
require_secret() {
|
||||
name="$1"
|
||||
value="$(printenv "$name" 2>/dev/null || true)"
|
||||
normalized="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$normalized" in
|
||||
""|*changeme*|*change-me*|*replace-me*)
|
||||
echo "telesrv: required secret $name is missing or still uses a placeholder" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
require_value() {
|
||||
name="$1"
|
||||
value="$(printenv "$name" 2>/dev/null || true)"
|
||||
normalized="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$normalized" in
|
||||
""|*changeme*|*change-me*|*replace-me*)
|
||||
echo "telesrv: required setting $name is missing or still uses a placeholder" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
initialize_server_key() {
|
||||
private_key="${TELESRV_RSA_KEY:-/var/lib/telesrv/server_rsa.pem}"
|
||||
identity_mode="$(printf '%s' "${TELESRV_RSA_IDENTITY_MODE:-generated}" | tr '[:upper:]' '[:lower:]')"
|
||||
embedded_private_key=/usr/share/telesrv/keys/test-server-rsa.pem.b64
|
||||
key_dir=$(dirname -- "$private_key")
|
||||
|
||||
mkdir -p "$key_dir"
|
||||
case "$identity_mode" in
|
||||
generated) ;;
|
||||
test)
|
||||
if [ ! -f "$private_key" ]; then
|
||||
if [ ! -r "$embedded_private_key" ]; then
|
||||
echo "telesrv: test RSA identity requested, but this image does not contain the published test key; use the server-test target or set TELESRV_RSA_IDENTITY_MODE=generated" >&2
|
||||
exit 66
|
||||
fi
|
||||
temporary_key="$private_key.tmp.$$"
|
||||
trap 'rm -f "$temporary_key"' EXIT HUP INT TERM
|
||||
base64 -d "$embedded_private_key" >"$temporary_key"
|
||||
chmod 0600 "$temporary_key"
|
||||
mv "$temporary_key" "$private_key"
|
||||
trap - EXIT HUP INT TERM
|
||||
echo "telesrv: WARNING using the published main test RSA identity; its private key is public" >&2
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "telesrv: TELESRV_RSA_IDENTITY_MODE must be test or generated" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -f "$private_key" ]; then
|
||||
if ! openssl rsa -in "$private_key" -check -noout >/dev/null 2>&1; then
|
||||
echo "telesrv: $private_key is not a valid RSA private key" >&2
|
||||
exit 65
|
||||
fi
|
||||
chmod 0600 "$private_key"
|
||||
fi
|
||||
}
|
||||
|
||||
case "$command_name" in
|
||||
telesrv)
|
||||
require_value TELESRV_ADVERTISE_IP
|
||||
require_value TELESRV_PUBLIC_BASE_URL
|
||||
require_value TELESRV_PUBLIC_WEB_BASE_URL
|
||||
require_secret TELESRV_POSTGRES_DSN
|
||||
require_secret TELESRV_REDIS_PASSWORD
|
||||
require_secret TELESRV_ADMIN_API_TOKEN
|
||||
require_secret TELESRV_TURN_SECRET
|
||||
initialize_server_key
|
||||
;;
|
||||
telesrv-admin)
|
||||
require_secret TELESRV_POSTGRES_DSN
|
||||
require_secret TELESRV_ADMIN_API_TOKEN
|
||||
require_secret TELESRV_ADMIN_SESSION_KEY
|
||||
admin_password="$(printenv TELESRV_ADMIN_UI_PASSWORD 2>/dev/null || true)"
|
||||
admin_token="$(printenv TELESRV_ADMIN_UI_TOKEN 2>/dev/null || true)"
|
||||
if [ -n "$admin_password" ]; then
|
||||
require_secret TELESRV_ADMIN_UI_PASSWORD
|
||||
elif [ -n "$admin_token" ]; then
|
||||
require_secret TELESRV_ADMIN_UI_TOKEN
|
||||
else
|
||||
echo "telesrv: TELESRV_ADMIN_UI_PASSWORD or TELESRV_ADMIN_UI_TOKEN is required" >&2
|
||||
exit 64
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
exec "$@"
|
||||
|
|
@ -2648,7 +2648,9 @@ CREATE TABLE public.secret_chats (
|
|||
history_deleted boolean DEFAULT false NOT NULL,
|
||||
random_id integer NOT NULL,
|
||||
date integer NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT secret_chats_nonzero_id CHECK ((chat_id <> 0)),
|
||||
CONSTRAINT secret_chats_random_id_is_chat_id CHECK ((chat_id = random_id))
|
||||
);
|
||||
|
||||
|
||||
|
|
@ -4524,13 +4526,6 @@ CREATE INDEX upload_parts_object_key_idx ON public.upload_parts USING btree (obj
|
|||
CREATE UNIQUE INDEX uq_emq_dedup ON public.encrypted_message_queue USING btree (receiver_auth_key_id, chat_id, random_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: uq_secret_chats_admin_random; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE UNIQUE INDEX uq_secret_chats_admin_random ON public.secret_chats USING btree (admin_auth_key_id, random_id) WHERE (state <> 'discarded'::text);
|
||||
|
||||
|
||||
--
|
||||
-- Name: user_channel_member_index_admined_public_idx; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
-- 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;
|
||||
12
deploy/migrations/0181_sticker_set_system_key_unique.up.sql
Normal file
12
deploy/migrations/0181_sticker_set_system_key_unique.up.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-- 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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
DROP INDEX IF EXISTS public.channel_messages_public_forward_source_seek_idx;
|
||||
DROP INDEX IF EXISTS public.channel_members_stats_period_idx;
|
||||
DROP INDEX IF EXISTS public.channel_message_viewers_stats_date_idx;
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
-- Bounded stats reads: event-time viewers, membership snapshots, and exact
|
||||
-- public-forward seek pagination by the durable MessageForward JSON shape.
|
||||
CREATE INDEX channel_message_viewers_stats_date_idx
|
||||
ON public.channel_message_viewers (channel_id, viewed_at, viewer_user_id, message_id);
|
||||
|
||||
CREATE INDEX channel_members_stats_period_idx
|
||||
ON public.channel_members (channel_id, joined_at, left_at, user_id);
|
||||
|
||||
CREATE INDEX channel_messages_public_forward_source_seek_idx
|
||||
ON public.channel_messages (
|
||||
(fwd_from #>> '{From,Type}'),
|
||||
(fwd_from #>> '{From,ID}'),
|
||||
(fwd_from #>> '{ChannelPost}'),
|
||||
message_date DESC,
|
||||
channel_id ASC,
|
||||
id DESC
|
||||
)
|
||||
WHERE NOT deleted AND fwd_from <> '{}'::jsonb;
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
CREATE OR REPLACE FUNCTION public.telesrv_notify_user_base_read_model() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
changed_id BIGINT;
|
||||
projection_changed BOOLEAN;
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
changed_id := OLD.id;
|
||||
projection_changed := true;
|
||||
ELSE
|
||||
changed_id := NEW.id;
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
projection_changed := true;
|
||||
ELSE
|
||||
projection_changed :=
|
||||
OLD.access_hash IS DISTINCT FROM NEW.access_hash OR
|
||||
OLD.phone IS DISTINCT FROM NEW.phone OR
|
||||
OLD.first_name IS DISTINCT FROM NEW.first_name OR
|
||||
OLD.last_name IS DISTINCT FROM NEW.last_name OR
|
||||
OLD.username IS DISTINCT FROM NEW.username OR
|
||||
OLD.country_code IS DISTINCT FROM NEW.country_code OR
|
||||
OLD.verified IS DISTINCT FROM NEW.verified OR
|
||||
OLD.support IS DISTINCT FROM NEW.support OR
|
||||
OLD.about IS DISTINCT FROM NEW.about OR
|
||||
OLD.default_history_ttl_period IS DISTINCT FROM NEW.default_history_ttl_period OR
|
||||
OLD.is_bot IS DISTINCT FROM NEW.is_bot OR
|
||||
OLD.bot_info_version IS DISTINCT FROM NEW.bot_info_version OR
|
||||
OLD.premium_expires_at IS DISTINCT FROM NEW.premium_expires_at OR
|
||||
OLD.emoji_status_document_id IS DISTINCT FROM NEW.emoji_status_document_id OR
|
||||
OLD.emoji_status_until IS DISTINCT FROM NEW.emoji_status_until OR
|
||||
OLD.color_set IS DISTINCT FROM NEW.color_set OR
|
||||
OLD.color IS DISTINCT FROM NEW.color OR
|
||||
OLD.color_background_emoji_id IS DISTINCT FROM NEW.color_background_emoji_id OR
|
||||
OLD.profile_color_set IS DISTINCT FROM NEW.profile_color_set OR
|
||||
OLD.profile_color IS DISTINCT FROM NEW.profile_color OR
|
||||
OLD.profile_color_background_emoji_id IS DISTINCT FROM NEW.profile_color_background_emoji_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF projection_changed THEN
|
||||
PERFORM telesrv_bump_read_model_version('user_base', changed_id, 'user', changed_id);
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
PERFORM telesrv_bump_read_model_version('contact_account', changed_id, 'user', changed_id);
|
||||
END IF;
|
||||
PERFORM telesrv_bump_read_model_version('contact_account', c.user_id, 'user', c.user_id)
|
||||
FROM contacts c
|
||||
WHERE c.contact_user_id = changed_id;
|
||||
PERFORM telesrv_bump_private_dialog_light_for_user(changed_id);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.telesrv_notify_user_channel_participants_read_model() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
changed_id BIGINT;
|
||||
old_id BIGINT;
|
||||
projection_changed BOOLEAN;
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
changed_id := OLD.id;
|
||||
projection_changed := true;
|
||||
ELSE
|
||||
changed_id := NEW.id;
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
projection_changed := true;
|
||||
PERFORM telesrv_bump_read_model_version('contact_account', changed_id, 'user', changed_id);
|
||||
ELSE
|
||||
old_id := OLD.id;
|
||||
projection_changed :=
|
||||
OLD.access_hash IS DISTINCT FROM NEW.access_hash OR
|
||||
OLD.phone IS DISTINCT FROM NEW.phone OR
|
||||
OLD.first_name IS DISTINCT FROM NEW.first_name OR
|
||||
OLD.last_name IS DISTINCT FROM NEW.last_name OR
|
||||
OLD.username IS DISTINCT FROM NEW.username OR
|
||||
OLD.country_code IS DISTINCT FROM NEW.country_code OR
|
||||
OLD.verified IS DISTINCT FROM NEW.verified OR
|
||||
OLD.support IS DISTINCT FROM NEW.support OR
|
||||
OLD.about IS DISTINCT FROM NEW.about OR
|
||||
OLD.default_history_ttl_period IS DISTINCT FROM NEW.default_history_ttl_period OR
|
||||
OLD.is_bot IS DISTINCT FROM NEW.is_bot OR
|
||||
OLD.bot_info_version IS DISTINCT FROM NEW.bot_info_version OR
|
||||
OLD.premium_expires_at IS DISTINCT FROM NEW.premium_expires_at OR
|
||||
OLD.emoji_status_document_id IS DISTINCT FROM NEW.emoji_status_document_id OR
|
||||
OLD.emoji_status_until IS DISTINCT FROM NEW.emoji_status_until OR
|
||||
OLD.color_set IS DISTINCT FROM NEW.color_set OR
|
||||
OLD.color IS DISTINCT FROM NEW.color OR
|
||||
OLD.color_background_emoji_id IS DISTINCT FROM NEW.color_background_emoji_id OR
|
||||
OLD.profile_color_set IS DISTINCT FROM NEW.profile_color_set OR
|
||||
OLD.profile_color IS DISTINCT FROM NEW.profile_color OR
|
||||
OLD.profile_color_background_emoji_id IS DISTINCT FROM NEW.profile_color_background_emoji_id;
|
||||
IF old_id IS DISTINCT FROM changed_id THEN
|
||||
PERFORM telesrv_bump_channel_participants_for_user(old_id);
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF projection_changed THEN
|
||||
PERFORM telesrv_bump_channel_participants_for_user(changed_id);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS users_release_collectible_phone_on_soft_delete ON public.users;
|
||||
CREATE TRIGGER users_release_collectible_phone_on_soft_delete
|
||||
BEFORE UPDATE OF deleted_at ON public.users
|
||||
FOR EACH ROW WHEN (OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL)
|
||||
EXECUTE FUNCTION public.release_soft_deleted_user_collectible_phone();
|
||||
129
deploy/migrations/20260901000004_logical_account_deletion.up.sql
Normal file
129
deploy/migrations/20260901000004_logical_account_deletion.up.sql
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
-- Human account deletion is a logical user tombstone. The deleted user keeps
|
||||
-- all relationship/history rows, so the users UPDATE must not fan out through
|
||||
-- every reverse contact, dialog and channel membership. A dedicated event
|
||||
-- invalidates the base-user and RPC projection caches as one coarse boundary.
|
||||
|
||||
-- Collectible phone ownership is an account asset, not the editable users.phone
|
||||
-- identity field. Logical deletion preserves it; physical user deletion keeps
|
||||
-- the separate 0171 release trigger.
|
||||
DROP TRIGGER IF EXISTS users_release_collectible_phone_on_soft_delete ON public.users;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.telesrv_notify_user_base_read_model() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
changed_id BIGINT;
|
||||
projection_changed BOOLEAN;
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE'
|
||||
AND OLD.deleted_at IS NULL
|
||||
AND NEW.deleted_at IS NOT NULL THEN
|
||||
PERFORM telesrv_bump_read_model_version('user_deleted', NEW.id, 'user', NEW.id);
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
changed_id := OLD.id;
|
||||
projection_changed := true;
|
||||
ELSE
|
||||
changed_id := NEW.id;
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
projection_changed := true;
|
||||
ELSE
|
||||
projection_changed :=
|
||||
OLD.access_hash IS DISTINCT FROM NEW.access_hash OR
|
||||
OLD.phone IS DISTINCT FROM NEW.phone OR
|
||||
OLD.first_name IS DISTINCT FROM NEW.first_name OR
|
||||
OLD.last_name IS DISTINCT FROM NEW.last_name OR
|
||||
OLD.username IS DISTINCT FROM NEW.username OR
|
||||
OLD.country_code IS DISTINCT FROM NEW.country_code OR
|
||||
OLD.verified IS DISTINCT FROM NEW.verified OR
|
||||
OLD.support IS DISTINCT FROM NEW.support OR
|
||||
OLD.about IS DISTINCT FROM NEW.about OR
|
||||
OLD.default_history_ttl_period IS DISTINCT FROM NEW.default_history_ttl_period OR
|
||||
OLD.is_bot IS DISTINCT FROM NEW.is_bot OR
|
||||
OLD.bot_info_version IS DISTINCT FROM NEW.bot_info_version OR
|
||||
OLD.premium_expires_at IS DISTINCT FROM NEW.premium_expires_at OR
|
||||
OLD.emoji_status_document_id IS DISTINCT FROM NEW.emoji_status_document_id OR
|
||||
OLD.emoji_status_until IS DISTINCT FROM NEW.emoji_status_until OR
|
||||
OLD.color_set IS DISTINCT FROM NEW.color_set OR
|
||||
OLD.color IS DISTINCT FROM NEW.color OR
|
||||
OLD.color_background_emoji_id IS DISTINCT FROM NEW.color_background_emoji_id OR
|
||||
OLD.profile_color_set IS DISTINCT FROM NEW.profile_color_set OR
|
||||
OLD.profile_color IS DISTINCT FROM NEW.profile_color OR
|
||||
OLD.profile_color_background_emoji_id IS DISTINCT FROM NEW.profile_color_background_emoji_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF projection_changed THEN
|
||||
PERFORM telesrv_bump_read_model_version('user_base', changed_id, 'user', changed_id);
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
PERFORM telesrv_bump_read_model_version('contact_account', changed_id, 'user', changed_id);
|
||||
END IF;
|
||||
PERFORM telesrv_bump_read_model_version('contact_account', c.user_id, 'user', c.user_id)
|
||||
FROM contacts c
|
||||
WHERE c.contact_user_id = changed_id;
|
||||
PERFORM telesrv_bump_private_dialog_light_for_user(changed_id);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.telesrv_notify_user_channel_participants_read_model() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
changed_id BIGINT;
|
||||
old_id BIGINT;
|
||||
projection_changed BOOLEAN;
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE'
|
||||
AND OLD.deleted_at IS NULL
|
||||
AND NEW.deleted_at IS NOT NULL THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
changed_id := OLD.id;
|
||||
projection_changed := true;
|
||||
ELSE
|
||||
changed_id := NEW.id;
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
projection_changed := true;
|
||||
PERFORM telesrv_bump_read_model_version('contact_account', changed_id, 'user', changed_id);
|
||||
ELSE
|
||||
old_id := OLD.id;
|
||||
projection_changed :=
|
||||
OLD.access_hash IS DISTINCT FROM NEW.access_hash OR
|
||||
OLD.phone IS DISTINCT FROM NEW.phone OR
|
||||
OLD.first_name IS DISTINCT FROM NEW.first_name OR
|
||||
OLD.last_name IS DISTINCT FROM NEW.last_name OR
|
||||
OLD.username IS DISTINCT FROM NEW.username OR
|
||||
OLD.country_code IS DISTINCT FROM NEW.country_code OR
|
||||
OLD.verified IS DISTINCT FROM NEW.verified OR
|
||||
OLD.support IS DISTINCT FROM NEW.support OR
|
||||
OLD.about IS DISTINCT FROM NEW.about OR
|
||||
OLD.default_history_ttl_period IS DISTINCT FROM NEW.default_history_ttl_period OR
|
||||
OLD.is_bot IS DISTINCT FROM NEW.is_bot OR
|
||||
OLD.bot_info_version IS DISTINCT FROM NEW.bot_info_version OR
|
||||
OLD.premium_expires_at IS DISTINCT FROM NEW.premium_expires_at OR
|
||||
OLD.emoji_status_document_id IS DISTINCT FROM NEW.emoji_status_document_id OR
|
||||
OLD.emoji_status_until IS DISTINCT FROM NEW.emoji_status_until OR
|
||||
OLD.color_set IS DISTINCT FROM NEW.color_set OR
|
||||
OLD.color IS DISTINCT FROM NEW.color OR
|
||||
OLD.color_background_emoji_id IS DISTINCT FROM NEW.color_background_emoji_id OR
|
||||
OLD.profile_color_set IS DISTINCT FROM NEW.profile_color_set OR
|
||||
OLD.profile_color IS DISTINCT FROM NEW.profile_color OR
|
||||
OLD.profile_color_background_emoji_id IS DISTINCT FROM NEW.profile_color_background_emoji_id;
|
||||
IF old_id IS DISTINCT FROM changed_id THEN
|
||||
PERFORM telesrv_bump_channel_participants_for_user(old_id);
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF projection_changed THEN
|
||||
PERFORM telesrv_bump_channel_participants_for_user(changed_id);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
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