fixes and improvements for new server settings menu
This commit is contained in:
parent
902f3606c2
commit
66f9c0bc1e
27 changed files with 2244 additions and 40 deletions
|
|
@ -410,6 +410,10 @@ TELESRV_DEFAULT_STICKER_SET_ID=0
|
||||||
# imported once (matched by filename); renaming a file re-imports it as a new
|
# imported once (matched by filename); renaming a file re-imports it as a new
|
||||||
# entry. Missing directory is skipped, not an error.
|
# entry. Missing directory is skipped, not an error.
|
||||||
TELESRV_GIF_SEED_DIR=data/gifs
|
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).
|
# 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.
|
# localfs: reject new uploads once real free disk bytes fall below this; <=0 disables.
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,23 @@ const hostStatsPollInterval = 5 * time.Second
|
||||||
|
|
||||||
const defaultAdminAPIAddr = "127.0.0.1:2599"
|
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() {
|
func main() {
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|
@ -95,6 +112,18 @@ type uiConfig struct {
|
||||||
// permissions, and the session/login response tells the frontend to hide
|
// permissions, and the session/login response tells the frontend to hide
|
||||||
// the "Third-party marks" nav entry and its routes.
|
// the "Third-party marks" nav entry and its routes.
|
||||||
HideThirdPartyVerification bool
|
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
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfig 通过 internal/config.Load() 加载 .env 配置文件与环境变量,
|
// loadConfig 通过 internal/config.Load() 加载 .env 配置文件与环境变量,
|
||||||
|
|
@ -121,6 +150,11 @@ func loadConfig() (uiConfig, error) {
|
||||||
}
|
}
|
||||||
sum := sha256.Sum256([]byte(appCfg.AdminSessionKey))
|
sum := sha256.Sum256([]byte(appCfg.AdminSessionKey))
|
||||||
|
|
||||||
|
repoRoot, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return uiConfig{}, fmt.Errorf("resolve repo root: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return uiConfig{
|
return uiConfig{
|
||||||
Addr: appCfg.AdminUIAddr,
|
Addr: appCfg.AdminUIAddr,
|
||||||
PostgresDSN: appCfg.PostgresDSN,
|
PostgresDSN: appCfg.PostgresDSN,
|
||||||
|
|
@ -132,6 +166,8 @@ func loadConfig() (uiConfig, error) {
|
||||||
Permissions: appCfg.AdminUIPermissions,
|
Permissions: appCfg.AdminUIPermissions,
|
||||||
HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
|
HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
|
||||||
BlobDir: appCfg.BlobDir,
|
BlobDir: appCfg.BlobDir,
|
||||||
|
IdentityDir: appCfg.IdentityDir,
|
||||||
|
RepoRoot: repoRoot,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,13 @@ const (
|
||||||
// curates the icon catalogue and strips granted marks.
|
// curates the icon catalogue and strips granted marks.
|
||||||
permissionBotVerificationReview = "botverification.review"
|
permissionBotVerificationReview = "botverification.review"
|
||||||
permissionBotVerificationManage = "botverification.manage"
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
type permissionsKey struct{}
|
type permissionsKey struct{}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import (
|
||||||
"telesrv/internal/admin"
|
"telesrv/internal/admin"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
"telesrv/internal/hoststats"
|
"telesrv/internal/hoststats"
|
||||||
|
"telesrv/internal/identity"
|
||||||
|
"telesrv/internal/procctl"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed web/dist
|
//go:embed web/dist
|
||||||
|
|
@ -31,6 +33,8 @@ type server struct {
|
||||||
hostStats *hoststats.Poller
|
hostStats *hoststats.Poller
|
||||||
web fs.FS
|
web fs.FS
|
||||||
webServer http.Handler
|
webServer http.Handler
|
||||||
|
identity *identity.Store
|
||||||
|
serverCtl *procctl.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
func newServer(cfg uiConfig, read *readStore, hostStats *hoststats.Poller) (*server, error) {
|
func newServer(cfg uiConfig, read *readStore, hostStats *hoststats.Poller) (*server, error) {
|
||||||
|
|
@ -44,6 +48,8 @@ func newServer(cfg uiConfig, read *readStore, hostStats *hoststats.Poller) (*ser
|
||||||
hostStats: hostStats,
|
hostStats: hostStats,
|
||||||
web: web,
|
web: web,
|
||||||
webServer: http.FileServer(http.FS(web)),
|
webServer: http.FileServer(http.FS(web)),
|
||||||
|
identity: identity.NewStore(cfg.IdentityDir),
|
||||||
|
serverCtl: procctl.NewManager(cfg.RepoRoot),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,6 +169,20 @@ func (s *server) routes() http.Handler {
|
||||||
mux.Handle("POST /api/actions/upsert-verification-icon", s.botVerificationManage(s.handleUpsertVerificationIconAPI))
|
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/set-verification-icon-active", s.botVerificationManage(s.handleSetVerificationIconActiveAPI))
|
||||||
mux.Handle("POST /api/actions/revoke-custom-verification", s.botVerificationManage(s.handleRevokeCustomVerificationAPI))
|
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/icon", s.serverManage(s.handleServerIconAPI))
|
||||||
|
mux.Handle("POST /api/actions/set-server-identity", s.serverManage(s.handleSetServerIdentityAPI))
|
||||||
|
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("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("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) {
|
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
writeAPIError(w, http.StatusNotFound, "api route not found")
|
writeAPIError(w, http.StatusNotFound, "api route not found")
|
||||||
})
|
})
|
||||||
|
|
@ -274,6 +294,12 @@ func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||||
"actor": actorFromContext(r.Context()),
|
"actor": actorFromContext(r.Context()),
|
||||||
"permissions": permissionsFromContext(r.Context()).List(),
|
"permissions": permissionsFromContext(r.Context()).List(),
|
||||||
"hide_third_party_verification": s.cfg.HideThirdPartyVerification,
|
"hide_third_party_verification": s.cfg.HideThirdPartyVerification,
|
||||||
|
// 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
264
cmd/telesrv-admin/serversettings.go
Normal file
264
cmd/telesrv-admin/serversettings.go
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"telesrv/internal/admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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) ---------------------------------
|
||||||
|
|
||||||
|
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, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- .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())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 and relaunches bin/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}))
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-C0CaRjmH.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-C0CaRjmH.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-Bt9UBcEE.js"></script>
|
<script type="module" crossorigin src="/assets/index-C0CaRjmH.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Dx2zWoFE.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ import type {
|
||||||
CollectibleUsernameDetail,
|
CollectibleUsernameDetail,
|
||||||
CollectibleUsernameListResponse,
|
CollectibleUsernameListResponse,
|
||||||
CommandResult,
|
CommandResult,
|
||||||
|
EnvGroup,
|
||||||
|
ServerIdentity,
|
||||||
|
ServerStatus,
|
||||||
GroupMessageDetail,
|
GroupMessageDetail,
|
||||||
GroupMessageListResponse,
|
GroupMessageListResponse,
|
||||||
MessageDetail,
|
MessageDetail,
|
||||||
|
|
@ -232,6 +235,11 @@ export const api = {
|
||||||
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { 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"),
|
gifCatalog: () => request<GifCatalogListResponse>("/api/gif-catalog"),
|
||||||
createGifCatalogEntry: (form: FormData) => request<CommandResult>("/api/actions/create-gif-catalog-entry", { method: "POST", body: form }),
|
createGifCatalogEntry: (form: FormData) => request<CommandResult>("/api/actions/create-gif-catalog-entry", { method: "POST", body: form }),
|
||||||
|
serverIdentity: () => request<ServerIdentity>("/api/server/identity"),
|
||||||
|
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"),
|
||||||
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
LogOut,
|
LogOut,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
|
Settings,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Smile,
|
Smile,
|
||||||
|
|
@ -18,7 +19,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { permissionBotVerificationReview, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
|
import { permissionBotVerificationReview, permissionServerManage, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
|
||||||
import { type Navigate, type RouteState, routeTitle } from "../routing";
|
import { type Navigate, type RouteState, routeTitle } from "../routing";
|
||||||
import { ThemeSwitch } from "../theme";
|
import { ThemeSwitch } from "../theme";
|
||||||
import { AppLink } from "./AppLink";
|
import { AppLink } from "./AppLink";
|
||||||
|
|
@ -57,6 +58,7 @@ export function Shell({
|
||||||
// Same reasoning for the third-party queue, which has its own right: the two
|
// 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.
|
// sections are granted independently, so one entry can be visible without the other.
|
||||||
const canReviewBotVerification = useCan(permissionBotVerificationReview);
|
const canReviewBotVerification = useCan(permissionBotVerificationReview);
|
||||||
|
const canManageServer = useCan(permissionServerManage);
|
||||||
// Third-party verification is additionally hidden by default (not fully
|
// Third-party verification is additionally hidden by default (not fully
|
||||||
// finished) regardless of what the session was granted -- see permissions.tsx.
|
// finished) regardless of what the session was granted -- see permissions.tsx.
|
||||||
const thirdPartyVerificationHidden = useThirdPartyVerificationHidden();
|
const thirdPartyVerificationHidden = useThirdPartyVerificationHidden();
|
||||||
|
|
@ -135,7 +137,13 @@ export function Shell({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{canManageServer && (
|
||||||
|
<NavLink icon={<Settings size={16} />} href="/server-settings" route={route} navigate={navigate}>{"Server Settings"}</NavLink>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
<div className="sidebar-status">
|
||||||
|
<span className="sidebar-label">{"Version: O7"}</span>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div className="workspace">
|
<div className="workspace">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { MessageDetailPage } from "./MessageDetailPage";
|
||||||
import { MessagesPage } from "./MessagesPage";
|
import { MessagesPage } from "./MessagesPage";
|
||||||
import { StickerSetsPage } from "./StickerSetsPage";
|
import { StickerSetsPage } from "./StickerSetsPage";
|
||||||
import { GifCatalogPage } from "./GifCatalogPage";
|
import { GifCatalogPage } from "./GifCatalogPage";
|
||||||
|
import { ServerSettingsPage } from "./ServerSettingsPage";
|
||||||
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
|
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
|
||||||
import { ModerationCasesPage } from "./ModerationCasesPage";
|
import { ModerationCasesPage } from "./ModerationCasesPage";
|
||||||
import { StoragePage } from "./StoragePage";
|
import { StoragePage } from "./StoragePage";
|
||||||
|
|
@ -27,6 +28,7 @@ import {
|
||||||
PermissionGate,
|
PermissionGate,
|
||||||
ThirdPartyVerificationHiddenGate,
|
ThirdPartyVerificationHiddenGate,
|
||||||
permissionBotVerificationReview,
|
permissionBotVerificationReview,
|
||||||
|
permissionServerManage,
|
||||||
permissionVerificationReview
|
permissionVerificationReview
|
||||||
} from "../permissions";
|
} from "../permissions";
|
||||||
|
|
||||||
|
|
@ -124,6 +126,13 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
||||||
if (route.path === "/gif-catalog") {
|
if (route.path === "/gif-catalog") {
|
||||||
return <GifCatalogPage />;
|
return <GifCatalogPage />;
|
||||||
}
|
}
|
||||||
|
if (route.path === "/server-settings") {
|
||||||
|
return (
|
||||||
|
<PermissionGate permission={permissionServerManage}>
|
||||||
|
<ServerSettingsPage />
|
||||||
|
</PermissionGate>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
|
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
|
||||||
return (
|
return (
|
||||||
<MessageDetailPage
|
<MessageDetailPage
|
||||||
|
|
|
||||||
443
cmd/telesrv-admin/web/src/pages/ServerSettingsPage.tsx
Normal file
443
cmd/telesrv-admin/web/src/pages/ServerSettingsPage.tsx
Normal file
|
|
@ -0,0 +1,443 @@
|
||||||
|
import { ChevronDown, ImageOff, ImagePlus, Loader2, RefreshCw, 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, Metric, PageFrame, SectionHead } from "../components/ui";
|
||||||
|
import type { 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
|
||||||
|
// Restart/Update. See cmd/telesrv-admin/serversettings.go for the backend.
|
||||||
|
export function ServerSettingsPage() {
|
||||||
|
return (
|
||||||
|
<PageFrame title={"Server Settings"} eyebrow={"Identity, .env, and process control -- mirrors the TUI panel"}>
|
||||||
|
<div className="stacked-sections">
|
||||||
|
<IdentitySection />
|
||||||
|
<ServerControlSection />
|
||||||
|
<EnvSection />
|
||||||
|
</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"} text={"Shown to clients auto-fetching this server's key -- takes effect immediately, no restart needed."} />
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
{!identity ? (
|
||||||
|
<LoadingSurface label={"Loading identity..."} />
|
||||||
|
) : (
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="entity-head-main">
|
||||||
|
<div className="avatar-edit-slot">
|
||||||
|
{identity.icon_ext && !iconFailed ? (
|
||||||
|
<img
|
||||||
|
className="avatar-photo-img"
|
||||||
|
src={api.serverIconURL() + `&b=${iconBust}`}
|
||||||
|
alt=""
|
||||||
|
style={{ width: 56, height: 56 }}
|
||||||
|
onError={() => setIconFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="avatar-fallback server-icon-fallback" style={{ width: 56, height: 56 }}>
|
||||||
|
<ImageOff size={20} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="icon-btn avatar-edit-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label={"Change server icon"}
|
||||||
|
title={"Change 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} onChange={(event) => setName(event.target.value)} /></label>
|
||||||
|
<label className="form-field"><span>{"Description"}</span><input value={description} maxLength={512} onChange={(event) => setDescription(event.target.value)} /></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="gift-table-actions">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerIconModal({ hasIcon, onClose, onDone }: { hasIcon: boolean; onClose: () => void; onDone: () => void }) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [previewURL, setPreviewURL] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
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>
|
||||||
|
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why the server icon is changing"} onChange={(event) => setReason(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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Server control (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.
|
||||||
|
function useAdminRestartWatcher() {
|
||||||
|
const [waiting, setWaiting] = useState(false);
|
||||||
|
const [timedOut, setTimedOut] = useState(false);
|
||||||
|
const cancelled = useRef(false);
|
||||||
|
|
||||||
|
const watch = useCallback(async (timeoutMs = 150000) => {
|
||||||
|
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) {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function RestartOverlay({ label, timedOut, onDismiss }: { label: string; 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={label}>
|
||||||
|
{timedOut ? (
|
||||||
|
<div className="command-body restart-overlay-body">
|
||||||
|
<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">
|
||||||
|
<Loader2 className="spin" size={28} />
|
||||||
|
<p>{label}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerControlSection() {
|
||||||
|
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [overlayLabel, setOverlayLabel] = useState("");
|
||||||
|
const restartWatcher = useAdminRestartWatcher();
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
setStatus(await api.serverStatus());
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section-block">
|
||||||
|
<SectionHead
|
||||||
|
title={"Process control"}
|
||||||
|
text={"Restart rebuilds and relaunches owpengram-server. Update also runs git pull first and rebuilds both binaries. Either way the admin panel bounces onto its (possibly rebuilt) binary too, a few seconds after the server comes back -- this page reloads itself once that's done."}
|
||||||
|
action={<button className="btn icon-text" type="button" onClick={() => void load()}><RefreshCw size={15} /> {"Refresh status"}</button>}
|
||||||
|
/>
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<div className="card-body">
|
||||||
|
{status && (
|
||||||
|
<div className="metric-row">
|
||||||
|
<Metric label={"owpengram-server"} value={status.ServerAlive ? `running (pid ${status.ServerPID})` : "stopped"} tone={status.ServerAlive ? "good" : "danger"} />
|
||||||
|
<Metric label={"admin panel"} value={status.AdminAlive ? `running (pid ${status.AdminPID})` : "stopped"} tone={status.AdminAlive ? "good" : "danger"} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="gift-table-actions">
|
||||||
|
<ActionButton
|
||||||
|
tone="warn"
|
||||||
|
label={"Restart server"}
|
||||||
|
path="/api/actions/restart-server"
|
||||||
|
payload={() => ({})}
|
||||||
|
onDone={() => {
|
||||||
|
setOverlayLabel("Restarting owpengram-server and the admin panel...");
|
||||||
|
void restartWatcher.watch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
tone="danger"
|
||||||
|
label={"Update (git pull + rebuild + restart)"}
|
||||||
|
path="/api/actions/update-server"
|
||||||
|
payload={() => ({})}
|
||||||
|
onDone={() => {
|
||||||
|
setOverlayLabel("Pulling, rebuilding, and restarting owpengram-server and the admin panel...");
|
||||||
|
void restartWatcher.watch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{restartWatcher.waiting && <RestartOverlay label={overlayLabel} timedOut={false} onDismiss={restartWatcher.dismiss} />}
|
||||||
|
{restartWatcher.timedOut && <RestartOverlay label={overlayLabel} timedOut={true} onDismiss={restartWatcher.dismiss} />}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- .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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,9 @@ export const permissionVerificationRevoke = "verification.revoke";
|
||||||
// verifier roster, the icon catalogue and taking a granted mark away.
|
// verifier roster, the icon catalogue and taking a granted mark away.
|
||||||
export const permissionBotVerificationReview = "botverification.review";
|
export const permissionBotVerificationReview = "botverification.review";
|
||||||
export const permissionBotVerificationManage = "botverification.manage";
|
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";
|
||||||
|
|
||||||
// GET /api/session is read once at boot; the panel keeps the answer here so a
|
// 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
|
// section the session may not use is hidden instead of rendered into a 403. This
|
||||||
|
|
|
||||||
|
|
@ -31,5 +31,6 @@ export function routeTitle(pathname: string): string {
|
||||||
if (pathname.startsWith("/messages")) return "Message Audit";
|
if (pathname.startsWith("/messages")) return "Message Audit";
|
||||||
if (pathname.startsWith("/stickers")) return "Stickers";
|
if (pathname.startsWith("/stickers")) return "Stickers";
|
||||||
if (pathname.startsWith("/gif-catalog")) return "GIFs";
|
if (pathname.startsWith("/gif-catalog")) return "GIFs";
|
||||||
|
if (pathname.startsWith("/server-settings")) return "Server Settings";
|
||||||
return "Operations Console";
|
return "Operations Console";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -928,3 +928,134 @@
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
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-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(440px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-overlay-body {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 28px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-overlay-body p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-overlay-actions {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -580,6 +580,10 @@ export type AdminSession = {
|
||||||
// 404, so this is a UI convenience on top of a real enforcement, not the
|
// 404, so this is a UI convenience on top of a real enforcement, not the
|
||||||
// enforcement itself.
|
// enforcement itself.
|
||||||
hide_third_party_verification?: boolean;
|
hide_third_party_verification?: 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminLoginResult = AdminSession & {
|
export type AdminLoginResult = AdminSession & {
|
||||||
|
|
@ -829,3 +833,31 @@ export type GroupMessageListResponse = {
|
||||||
limit: number;
|
limit: number;
|
||||||
rows: GroupMessageRow[];
|
rows: GroupMessageRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ServerIdentity = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon_ext?: 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;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ import (
|
||||||
"telesrv/internal/otpdelivery"
|
"telesrv/internal/otpdelivery"
|
||||||
otpsmtp "telesrv/internal/otpdelivery/smtp"
|
otpsmtp "telesrv/internal/otpdelivery/smtp"
|
||||||
otpwebhook "telesrv/internal/otpdelivery/webhook"
|
otpwebhook "telesrv/internal/otpdelivery/webhook"
|
||||||
|
"telesrv/internal/procctl"
|
||||||
"telesrv/internal/rpc"
|
"telesrv/internal/rpc"
|
||||||
"telesrv/internal/seed/catalog"
|
"telesrv/internal/seed/catalog"
|
||||||
"telesrv/internal/sfu"
|
"telesrv/internal/sfu"
|
||||||
|
|
@ -1547,6 +1548,7 @@ func run(logger *zap.Logger) error {
|
||||||
DC: cfg.DC,
|
DC: cfg.DC,
|
||||||
StrictDC: cfg.StrictDCCheck,
|
StrictDC: cfg.StrictDCCheck,
|
||||||
RSAKey: rsaKey,
|
RSAKey: rsaKey,
|
||||||
|
IdentityDir: cfg.IdentityDir,
|
||||||
LayerRPC: router,
|
LayerRPC: router,
|
||||||
AuthKeys: authKeyStore,
|
AuthKeys: authKeyStore,
|
||||||
ActiveSessions: activeSessions,
|
ActiveSessions: activeSessions,
|
||||||
|
|
@ -1581,6 +1583,23 @@ func run(logger *zap.Logger) error {
|
||||||
zap.Uint("schema_version", migrationStatus.Version),
|
zap.Uint("schema_version", migrationStatus.Version),
|
||||||
zap.String("blob_backend", "localfs"),
|
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 {
|
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,11 @@ type Config struct {
|
||||||
// StickerSeedDir, this expects no export manifest, just raw files dropped
|
// StickerSeedDir, this expects no export manifest, just raw files dropped
|
||||||
// in. Missing directory is skipped, not an error.
|
// in. Missing directory is skipped, not an error.
|
||||||
GifSeedDir string
|
GifSeedDir string
|
||||||
|
// IdentityDir holds the admin-editable server identity (name/description
|
||||||
|
// in identity.json, icon as icon.<ext>) served over /owpengram/server-info
|
||||||
|
// and /owpengram/server-icon -- see internal/identity. Read fresh on every
|
||||||
|
// request, so admin edits apply immediately with no server restart.
|
||||||
|
IdentityDir string
|
||||||
// BusinessAIProvider 控制服务端 Business automation 回复生成器。
|
// BusinessAIProvider 控制服务端 Business automation 回复生成器。
|
||||||
// 空值/"echo" 回显触发私聊文本,用于跑通后续 AI provider 链路;
|
// 空值/"echo" 回显触发私聊文本,用于跑通后续 AI provider 链路;
|
||||||
// "template" 使用 quick reply 模板。
|
// "template" 使用 quick reply 模板。
|
||||||
|
|
@ -776,6 +781,7 @@ func Load() (Config, error) {
|
||||||
StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 300),
|
StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 300),
|
||||||
PremiumPromoSeedDir: envOr("TELESRV_PREMIUM_PROMO_SEED_DIR", "data/premium-promo"),
|
PremiumPromoSeedDir: envOr("TELESRV_PREMIUM_PROMO_SEED_DIR", "data/premium-promo"),
|
||||||
GifSeedDir: envOr("TELESRV_GIF_SEED_DIR", "data/gifs"),
|
GifSeedDir: envOr("TELESRV_GIF_SEED_DIR", "data/gifs"),
|
||||||
|
IdentityDir: envOr("TELESRV_IDENTITY_DIR", "data/identity"),
|
||||||
MapboxToken: envOr("TELESRV_MAPBOX_TOKEN", ""),
|
MapboxToken: envOr("TELESRV_MAPBOX_TOKEN", ""),
|
||||||
MapTileCacheDir: envOr("TELESRV_MAPTILE_CACHE_DIR", "data/maptiles"),
|
MapTileCacheDir: envOr("TELESRV_MAPTILE_CACHE_DIR", "data/maptiles"),
|
||||||
ExternalMediaEnable: envBoolOr("TELESRV_EXTERNAL_MEDIA_ENABLE", true),
|
ExternalMediaEnable: envBoolOr("TELESRV_EXTERNAL_MEDIA_ENABLE", true),
|
||||||
|
|
|
||||||
152
internal/identity/identity.go
Normal file
152
internal/identity/identity.go
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
// Package identity stores the admin-editable server name/description/icon
|
||||||
|
// shown to clients over the same-port HTTP endpoints in internal/mtprotoedge
|
||||||
|
// (/owpengram/server-info, /owpengram/server-icon). It is deliberately not
|
||||||
|
// part of internal/config's Config: config is loaded once at process start
|
||||||
|
// from .env, while identity is meant to be edited from the admin web panel
|
||||||
|
// and take effect immediately, with no server restart -- so it lives as
|
||||||
|
// plain files on disk, read fresh on every request instead of cached in
|
||||||
|
// memory.
|
||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
metaFileName = "identity.json"
|
||||||
|
iconBaseName = "icon"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Info is the editable identity shown to clients.
|
||||||
|
type Info struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
// IconExt is the icon file's extension (e.g. ".png"), empty when no
|
||||||
|
// icon has been uploaded. Kept alongside Name/Description so Store can
|
||||||
|
// find the icon file without a directory listing.
|
||||||
|
IconExt string `json:"icon_ext,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store reads/writes Info and the icon file under a directory (typically
|
||||||
|
// Config.IdentityDir). All methods are safe to call from multiple goroutines
|
||||||
|
// and multiple processes (the admin binary writes, the main server binary
|
||||||
|
// reads) -- writes are atomic via a temp file + rename.
|
||||||
|
type Store struct {
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(dir string) *Store {
|
||||||
|
return &Store{dir: dir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) metaPath() string {
|
||||||
|
return filepath.Join(s.dir, metaFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) iconPath(ext string) string {
|
||||||
|
return filepath.Join(s.dir, iconBaseName+ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reads the current identity. A missing file is not an error -- it just
|
||||||
|
// means nothing has been configured yet, so Info{} (all empty) is returned.
|
||||||
|
func (s *Store) Get() (Info, error) {
|
||||||
|
data, err := os.ReadFile(s.metaPath())
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return Info{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Info{}, fmt.Errorf("identity: read: %w", err)
|
||||||
|
}
|
||||||
|
var info Info
|
||||||
|
if err := json.Unmarshal(data, &info); err != nil {
|
||||||
|
return Info{}, fmt.Errorf("identity: decode: %w", err)
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetText updates name/description, preserving whatever icon is already
|
||||||
|
// configured.
|
||||||
|
func (s *Store) SetText(name, description string) error {
|
||||||
|
info, err := s.Get()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info.Name = strings.TrimSpace(name)
|
||||||
|
info.Description = strings.TrimSpace(description)
|
||||||
|
return s.save(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetIcon replaces the icon file (removing any previous one under a
|
||||||
|
// different extension) and records its extension in identity.json.
|
||||||
|
// ext must include the leading dot (e.g. ".png").
|
||||||
|
func (s *Store) SetIcon(data []byte, ext string) error {
|
||||||
|
info, err := s.Get()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(s.dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("identity: mkdir: %w", err)
|
||||||
|
}
|
||||||
|
if info.IconExt != "" && info.IconExt != ext {
|
||||||
|
_ = os.Remove(s.iconPath(info.IconExt))
|
||||||
|
}
|
||||||
|
if err := writeFileAtomic(s.iconPath(ext), data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("identity: write icon: %w", err)
|
||||||
|
}
|
||||||
|
info.IconExt = ext
|
||||||
|
return s.save(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveIcon deletes the configured icon, if any.
|
||||||
|
func (s *Store) RemoveIcon() error {
|
||||||
|
info, err := s.Get()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IconExt == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = os.Remove(s.iconPath(info.IconExt))
|
||||||
|
info.IconExt = ""
|
||||||
|
return s.save(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon returns the icon's raw bytes and its file extension, or ("", nil,
|
||||||
|
// false) when no icon is configured.
|
||||||
|
func (s *Store) Icon() (data []byte, ext string, ok bool) {
|
||||||
|
info, err := s.Get()
|
||||||
|
if err != nil || info.IconExt == "" {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(s.iconPath(info.IconExt))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
return raw, info.IconExt, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) save(info Info) error {
|
||||||
|
if err := os.MkdirAll(s.dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("identity: mkdir: %w", err)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(info, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("identity: encode: %w", err)
|
||||||
|
}
|
||||||
|
if err := writeFileAtomic(s.metaPath(), data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("identity: write: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, perm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
95
internal/identity/identity_test.go
Normal file
95
internal/identity/identity_test.go
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStoreTextRoundTrip(t *testing.T) {
|
||||||
|
s := NewStore(t.TempDir())
|
||||||
|
|
||||||
|
info, err := s.Get()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info != (Info{}) {
|
||||||
|
t.Fatalf("expected zero-value Info before any write, got %+v", info)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.SetText(" OwpenGram ", " A self-hosted server. "); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err = s.Get()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Name != "OwpenGram" || info.Description != "A self-hosted server." {
|
||||||
|
t.Fatalf("got %+v", info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreIconRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := NewStore(dir)
|
||||||
|
|
||||||
|
if _, _, ok := s.Icon(); ok {
|
||||||
|
t.Fatal("expected no icon before any upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
png := []byte{0x89, 'P', 'N', 'G', 1, 2, 3}
|
||||||
|
if err := s.SetIcon(png, ".png"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, ext, ok := s.Icon()
|
||||||
|
if !ok || ext != ".png" || string(data) != string(png) {
|
||||||
|
t.Fatalf("Icon() = %v, %q, %v", data, ext, ok)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "icon.png")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replacing with a different extension removes the old file.
|
||||||
|
jpg := []byte{0xFF, 0xD8, 0xFF}
|
||||||
|
if err := s.SetIcon(jpg, ".jpg"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "icon.png")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("old icon.png should have been removed")
|
||||||
|
}
|
||||||
|
data, ext, ok = s.Icon()
|
||||||
|
if !ok || ext != ".jpg" || string(data) != string(jpg) {
|
||||||
|
t.Fatalf("Icon() after replace = %v, %q, %v", data, ext, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.RemoveIcon(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, ok := s.Icon(); ok {
|
||||||
|
t.Fatal("expected no icon after RemoveIcon")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name/description set earlier (none here) must survive icon churn --
|
||||||
|
// Get() after all this should still report a clean, non-error zero name.
|
||||||
|
info, err := s.Get()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.IconExt != "" {
|
||||||
|
t.Fatalf("IconExt should be empty after removal, got %q", info.IconExt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorePreservesIconAcrossTextEdits(t *testing.T) {
|
||||||
|
s := NewStore(t.TempDir())
|
||||||
|
if err := s.SetIcon([]byte{1, 2, 3}, ".webp"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.SetText("New Name", "New description"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, ext, ok := s.Icon()
|
||||||
|
if !ok || ext != ".webp" {
|
||||||
|
t.Fatalf("icon lost after unrelated SetText: ext=%q ok=%v", ext, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ import (
|
||||||
"github.com/iamxvbaba/td/transport"
|
"github.com/iamxvbaba/td/transport"
|
||||||
|
|
||||||
"github.com/iamxvbaba/td/tlprofile"
|
"github.com/iamxvbaba/td/tlprofile"
|
||||||
|
"telesrv/internal/identity"
|
||||||
"telesrv/internal/store"
|
"telesrv/internal/store"
|
||||||
"telesrv/internal/store/memory"
|
"telesrv/internal/store/memory"
|
||||||
)
|
)
|
||||||
|
|
@ -343,6 +344,11 @@ type Options struct {
|
||||||
StrictDC bool
|
StrictDC bool
|
||||||
// RSAKey 是 server RSA 私钥,用于密钥交换。nil 时无法完成握手。
|
// RSAKey 是 server RSA 私钥,用于密钥交换。nil 时无法完成握手。
|
||||||
RSAKey *rsa.PrivateKey
|
RSAKey *rsa.PrivateKey
|
||||||
|
// IdentityDir, when non-empty, enables serving the admin-editable server
|
||||||
|
// name/description/icon over ServerInfoPath/ServerIconPath (see
|
||||||
|
// internal/identity). Empty disables the feature -- those fields are
|
||||||
|
// simply omitted, RSA key/DC info still serve as before.
|
||||||
|
IdentityDir string
|
||||||
// AuthKeys 持久化 auth key。默认内存实现。
|
// AuthKeys 持久化 auth key。默认内存实现。
|
||||||
AuthKeys store.AuthKeyStore
|
AuthKeys store.AuthKeyStore
|
||||||
// ActiveSessions 管理活跃连接。默认新建;传入时可让 RPC 层共享同一注册表。
|
// ActiveSessions 管理活跃连接。默认新建;传入时可让 RPC 层共享同一注册表。
|
||||||
|
|
@ -523,6 +529,9 @@ type Server struct {
|
||||||
// from a host:port instead of a manual openssl + copy-paste.
|
// from a host:port instead of a manual openssl + copy-paste.
|
||||||
pubKeyPEM []byte
|
pubKeyPEM []byte
|
||||||
|
|
||||||
|
// identityStore is nil when Options.IdentityDir is empty (feature off).
|
||||||
|
identityStore *identity.Store
|
||||||
|
|
||||||
// onFrame 是测试钩子:收到一帧时回调其字节数;生产为 nil。
|
// onFrame 是测试钩子:收到一帧时回调其字节数;生产为 nil。
|
||||||
onFrame func(n int)
|
onFrame func(n int)
|
||||||
}
|
}
|
||||||
|
|
@ -582,6 +591,9 @@ func New(opts Options) *Server {
|
||||||
rpcRewrap: newRPCRewrapRegistry(opts.RPCGlobalMaxTasks),
|
rpcRewrap: newRPCRewrapRegistry(opts.RPCGlobalMaxTasks),
|
||||||
admission: newAdmissionController(opts.MaxConnections, opts.MaxConnectionsPerIP, opts.MaxConcurrentHandshakes),
|
admission: newAdmissionController(opts.MaxConnections, opts.MaxConnectionsPerIP, opts.MaxConcurrentHandshakes),
|
||||||
}
|
}
|
||||||
|
if opts.IdentityDir != "" {
|
||||||
|
server.identityStore = identity.NewStore(opts.IdentityDir)
|
||||||
|
}
|
||||||
conns.setLogicalSessionReleaseHook(func(key sessionKey) {
|
conns.setLogicalSessionReleaseHook(func(key sessionKey) {
|
||||||
server.rpcResults.forgetSession(key.authKeyID, key.sessionID)
|
server.rpcResults.forgetSession(key.authKeyID, key.sessionID)
|
||||||
})
|
})
|
||||||
|
|
@ -718,6 +730,7 @@ func (s *Server) serveMixed(ctx context.Context, ln net.Listener) error {
|
||||||
websocketRouteHandler(wsHandler, s.websocketOrigins),
|
websocketRouteHandler(wsHandler, s.websocketOrigins),
|
||||||
s.dc,
|
s.dc,
|
||||||
s.pubKeyPEM,
|
s.pubKeyPEM,
|
||||||
|
s.identityStore,
|
||||||
),
|
),
|
||||||
ReadHeaderTimeout: minDuration(10*time.Second, s.handshakeTimeout),
|
ReadHeaderTimeout: minDuration(10*time.Second, s.handshakeTimeout),
|
||||||
BaseContext: func(net.Listener) context.Context {
|
BaseContext: func(net.Listener) context.Context {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,11 @@ import (
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"telesrv/internal/identity"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServerInfoPath is the well-known same-port HTTP path a client can GET to
|
// ServerInfoPath is the well-known same-port HTTP path a client can GET to
|
||||||
|
|
@ -20,13 +24,26 @@ import (
|
||||||
// active, i.e. TELESRV_WEBSOCKET_ENABLE=true (the default).
|
// active, i.e. TELESRV_WEBSOCKET_ENABLE=true (the default).
|
||||||
const ServerInfoPath = "/owpengram/server-info"
|
const ServerInfoPath = "/owpengram/server-info"
|
||||||
|
|
||||||
|
// ServerIconPath serves the server's icon as a raw image, separately from
|
||||||
|
// ServerInfoPath's JSON -- avoids inflating every server-info fetch with a
|
||||||
|
// base64 blob when most callers only need it once, and lets it be cached/
|
||||||
|
// requested independently (e.g. an <img src> tag).
|
||||||
|
const ServerIconPath = "/owpengram/server-icon"
|
||||||
|
|
||||||
// ServerInfoResponse is ServerInfoPath's JSON body. RSAPublicKeyPEM is the
|
// ServerInfoResponse is ServerInfoPath's JSON body. RSAPublicKeyPEM is the
|
||||||
// PKCS#1 "RSA PUBLIC KEY" PEM block -- the same format
|
// PKCS#1 "RSA PUBLIC KEY" PEM block -- the same format
|
||||||
// `openssl rsa -RSAPublicKey_out` produces, and what the client's "Add
|
// `openssl rsa -RSAPublicKey_out` produces, and what the client's "Add
|
||||||
// Server" RSA key field already expects verbatim.
|
// Server" RSA key field already expects verbatim. Name/Description are
|
||||||
|
// admin-edited via internal/identity and optional -- clients should treat
|
||||||
|
// blank as "no override" and keep whatever the user typed.
|
||||||
type ServerInfoResponse struct {
|
type ServerInfoResponse struct {
|
||||||
DCID int `json:"dc_id"`
|
DCID int `json:"dc_id"`
|
||||||
RSAPublicKeyPEM string `json:"rsa_public_key_pem"`
|
RSAPublicKeyPEM string `json:"rsa_public_key_pem"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
// HasIcon tells the client whether GET ServerIconPath is worth calling,
|
||||||
|
// without requiring a separate round trip just to find out.
|
||||||
|
HasIcon bool `json:"has_icon,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// rsaPublicKeyPEM renders key's public half as a PKCS#1 PEM block, matching
|
// rsaPublicKeyPEM renders key's public half as a PKCS#1 PEM block, matching
|
||||||
|
|
@ -41,34 +58,108 @@ func rsaPublicKeyPEM(key *rsa.PrivateKey) []byte {
|
||||||
return pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: der})
|
return pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: der})
|
||||||
}
|
}
|
||||||
|
|
||||||
// serverInfoHTTPHandler serves ServerInfoResponse at ServerInfoPath and
|
// serverInfoHTTPHandler serves ServerInfoResponse at ServerInfoPath and the
|
||||||
// delegates every other path to next (the existing WebSocket route
|
// raw icon bytes at ServerIconPath, delegating every other path to next (the
|
||||||
// handler). pubKeyPEM is nil when the server has no RSA key configured
|
// existing WebSocket route handler). pubKeyPEM is nil when the server has no
|
||||||
// (shouldn't happen in production -- handshakes would already be broken --
|
// RSA key configured (shouldn't happen in production -- handshakes would
|
||||||
// but a client asking anyway gets 503, not a panic or an empty key).
|
// already be broken -- but a client asking anyway gets 503, not a panic or
|
||||||
func serverInfoHTTPHandler(next http.Handler, dc int, pubKeyPEM []byte) http.Handler {
|
// an empty key). identityStore may be nil (identity feature disabled);
|
||||||
|
// Name/Description/icon are then simply omitted, RSA key/DC still serve.
|
||||||
|
func serverInfoHTTPHandler(
|
||||||
|
next http.Handler,
|
||||||
|
dc int,
|
||||||
|
pubKeyPEM []byte,
|
||||||
|
identityStore *identity.Store,
|
||||||
|
) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != ServerInfoPath {
|
switch r.URL.Path {
|
||||||
|
case ServerInfoPath:
|
||||||
|
serveServerInfo(w, r, dc, pubKeyPEM, identityStore)
|
||||||
|
case ServerIconPath:
|
||||||
|
serveServerIcon(w, r, identityStore)
|
||||||
|
default:
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
||||||
w.Header().Set("Allow", "GET, HEAD")
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(pubKeyPEM) == 0 {
|
|
||||||
http.Error(w, "server key not configured", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
if r.Method == http.MethodHead {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = json.NewEncoder(w).Encode(ServerInfoResponse{
|
|
||||||
DCID: dc,
|
|
||||||
RSAPublicKeyPEM: string(pubKeyPEM),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serveServerInfo(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
dc int,
|
||||||
|
pubKeyPEM []byte,
|
||||||
|
identityStore *identity.Store,
|
||||||
|
) {
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
w.Header().Set("Allow", "GET, HEAD")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(pubKeyPEM) == 0 {
|
||||||
|
http.Error(w, "server key not configured", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := ServerInfoResponse{
|
||||||
|
DCID: dc,
|
||||||
|
RSAPublicKeyPEM: string(pubKeyPEM),
|
||||||
|
}
|
||||||
|
if identityStore != nil {
|
||||||
|
if info, err := identityStore.Get(); err == nil {
|
||||||
|
resp.Name = info.Name
|
||||||
|
resp.Description = info.Description
|
||||||
|
resp.HasIcon = info.IconExt != ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "encode server info", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
// See serveServerIcon's identical Content-Length comment -- same reason:
|
||||||
|
// keeps net/http from chunked-encoding a response the desktop client's
|
||||||
|
// raw-socket parser can't decode. name/description are short today, but
|
||||||
|
// nothing enforces that server-side, so this isn't purely defensive.
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveServerIcon(w http.ResponseWriter, r *http.Request, identityStore *identity.Store) {
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
w.Header().Set("Allow", "GET, HEAD")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if identityStore == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, ext, ok := identityStore.Icon()
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentType := mime.TypeByExtension(ext)
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
// Explicit Content-Length keeps net/http from switching to
|
||||||
|
// Transfer-Encoding: chunked, which it otherwise does automatically once
|
||||||
|
// a single Write() exceeds its small internal sniff buffer (true for
|
||||||
|
// basically any real icon, easily hundreds of KB) -- the desktop
|
||||||
|
// client's same-port fetch is a hand-rolled raw-socket HTTP/1.1 parser
|
||||||
|
// (see FetchServerIcon in owpengram_servers.cpp), not a real HTTP
|
||||||
|
// client, and has no chunked-decoding logic: it would otherwise treat
|
||||||
|
// the chunk-size-prefixed framing as image bytes and fail to decode.
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
|
|
||||||
105
internal/mtprotoedge/server_info_http_test.go
Normal file
105
internal/mtprotoedge/server_info_http_test.go
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
package mtprotoedge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"telesrv/internal/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServeServerInfoIncludesIdentity(t *testing.T) {
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pem := rsaPublicKeyPEM(key)
|
||||||
|
|
||||||
|
store := identity.NewStore(t.TempDir())
|
||||||
|
if err := store.SetText("OwpenGram", "A self-hosted server."); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fallback := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })
|
||||||
|
h := serverInfoHTTPHandler(fallback, 2, pem, store)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, ServerInfoPath, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
var resp ServerInfoResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Name != "OwpenGram" || resp.Description != "A self-hosted server." {
|
||||||
|
t.Fatalf("got %+v", resp)
|
||||||
|
}
|
||||||
|
if resp.HasIcon {
|
||||||
|
t.Fatal("has_icon should be false before any upload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeServerInfoWithoutIdentityStore(t *testing.T) {
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pem := rsaPublicKeyPEM(key)
|
||||||
|
|
||||||
|
fallback := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })
|
||||||
|
h := serverInfoHTTPHandler(fallback, 2, pem, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, ServerInfoPath, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
var resp ServerInfoResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Name != "" || resp.Description != "" || resp.HasIcon {
|
||||||
|
t.Fatalf("expected empty identity fields when store is nil, got %+v", resp)
|
||||||
|
}
|
||||||
|
if resp.RSAPublicKeyPEM == "" {
|
||||||
|
t.Fatal("RSA key should still serve when identity store is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeServerIcon(t *testing.T) {
|
||||||
|
store := identity.NewStore(t.TempDir())
|
||||||
|
fallback := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })
|
||||||
|
h := serverInfoHTTPHandler(fallback, 2, []byte("pem"), store)
|
||||||
|
|
||||||
|
// No icon configured yet -> 404.
|
||||||
|
req := httptest.NewRequest(http.MethodGet, ServerIconPath, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 before upload", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
png := []byte{0x89, 'P', 'N', 'G', 1, 2, 3, 4}
|
||||||
|
if err := store.SetIcon(png, ".png"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodGet, ServerIconPath, nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec.Body.String() != string(png) {
|
||||||
|
t.Fatal("icon body mismatch")
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
|
||||||
|
t.Fatalf("Content-Type = %q", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
35
internal/procctl/admin_restart_test.go
Normal file
35
internal/procctl/admin_restart_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package procctl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandlePendingAdminRestartNoop(t *testing.T) {
|
||||||
|
m := NewManager(t.TempDir())
|
||||||
|
restarted, err := m.HandlePendingAdminRestart(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if restarted {
|
||||||
|
t.Fatal("expected no-op when no restart was requested")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePendingAdminRestartFlagRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
statePath := filepath.Join(dir, stateFileName)
|
||||||
|
if err := os.WriteFile(statePath, []byte(`{"server_pid":123,"admin_pid":0,"pending_admin_restart":true}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := NewManager(dir)
|
||||||
|
st := m.loadState()
|
||||||
|
if !st.PendingAdminRestart {
|
||||||
|
t.Fatal("expected PendingAdminRestart to round-trip from JSON")
|
||||||
|
}
|
||||||
|
if st.ServerPID != 123 {
|
||||||
|
t.Fatalf("ServerPID = %d, want 123 (existing fields must survive)", st.ServerPID)
|
||||||
|
}
|
||||||
|
}
|
||||||
135
internal/procctl/env_test.go
Normal file
135
internal/procctl/env_test.go
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
package procctl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// findRepoRoot walks up from the test's working directory looking for
|
||||||
|
// .env.example -- the same file server-panel.py parses -- so this test
|
||||||
|
// exercises the real file, not a synthetic fixture that could drift from it.
|
||||||
|
func findRepoRoot(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, ".env.example")); err == nil {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
t.Fatal(".env.example not found above test directory")
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadEnvGroupsParsesRealTemplate(t *testing.T) {
|
||||||
|
root := findRepoRoot(t)
|
||||||
|
m := NewManager(root)
|
||||||
|
groups, err := m.ReadEnvGroups()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(groups) == 0 {
|
||||||
|
t.Fatal("expected at least one group from .env.example")
|
||||||
|
}
|
||||||
|
found := map[string]bool{}
|
||||||
|
for _, g := range groups {
|
||||||
|
if g.Title == "" {
|
||||||
|
t.Errorf("group with empty title, description=%q", g.Description)
|
||||||
|
}
|
||||||
|
if len(g.Fields) == 0 {
|
||||||
|
t.Errorf("group %q has no fields", g.Title)
|
||||||
|
}
|
||||||
|
for _, f := range g.Fields {
|
||||||
|
if !strings.HasPrefix(f.Key, "TELESRV_") {
|
||||||
|
t.Errorf("field key %q missing TELESRV_ prefix", f.Key)
|
||||||
|
}
|
||||||
|
found[f.Key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A couple of fields we know exist in the real file (checked above) --
|
||||||
|
// pins the parser to the actual format, not just "produces something".
|
||||||
|
for _, key := range []string{"TELESRV_LISTEN", "TELESRV_ADVERTISE_IP", "TELESRV_DC"} {
|
||||||
|
if !found[key] {
|
||||||
|
t.Errorf("expected field %s in parsed groups", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The advanced/internal-tuning section (after the "# ===..." banner) is
|
||||||
|
// deliberately excluded -- mirrors server-panel.py's parse_env_template.
|
||||||
|
if found["TELESRV_MTPROTO_RPC_MAX_INFLIGHT"] {
|
||||||
|
t.Error("advanced/internal field leaked into panel-visible groups")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEnvValuesRoundTrip(t *testing.T) {
|
||||||
|
root := findRepoRoot(t)
|
||||||
|
tmpl, err := os.ReadFile(filepath.Join(root, ".env.example"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".env.example"), tmpl, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := NewManager(dir)
|
||||||
|
groups, err := m.ReadEnvGroups()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(groups) == 0 {
|
||||||
|
t.Fatal("no groups parsed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.WriteEnvValues(map[string]string{
|
||||||
|
"TELESRV_ADVERTISE_IP": "203.0.113.5",
|
||||||
|
"TELESRV_DC": "3",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
groups2, err := m.ReadEnvGroups()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
values := map[string]string{}
|
||||||
|
for _, g := range groups2 {
|
||||||
|
for _, f := range g.Fields {
|
||||||
|
values[f.Key] = f.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if values["TELESRV_ADVERTISE_IP"] != "203.0.113.5" {
|
||||||
|
t.Errorf("TELESRV_ADVERTISE_IP = %q, want 203.0.113.5", values["TELESRV_ADVERTISE_IP"])
|
||||||
|
}
|
||||||
|
if values["TELESRV_DC"] != "3" {
|
||||||
|
t.Errorf("TELESRV_DC = %q, want 3", values["TELESRV_DC"])
|
||||||
|
}
|
||||||
|
// A field never touched by WriteEnvValues should still read back its
|
||||||
|
// template default -- confirms the file's other lines/comments/layout
|
||||||
|
// survived the rewrite untouched.
|
||||||
|
if values["TELESRV_LISTEN"] == "" {
|
||||||
|
t.Error("TELESRV_LISTEN lost its default after an unrelated field was written")
|
||||||
|
}
|
||||||
|
|
||||||
|
envPath := filepath.Join(dir, ".env")
|
||||||
|
envData, err := os.ReadFile(envPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(envData), "TELESRV_ADVERTISE_IP=203.0.113.5") {
|
||||||
|
t.Error(".env does not contain the written value verbatim")
|
||||||
|
}
|
||||||
|
// The file's descriptive comments (the reason server-panel.py rewrites
|
||||||
|
// from the template instead of dumping bare key=value pairs) must
|
||||||
|
// survive too.
|
||||||
|
if !strings.Contains(string(envData), "Your server's public IP address.") {
|
||||||
|
t.Error(".env lost .env.example's comment lines on write")
|
||||||
|
}
|
||||||
|
}
|
||||||
581
internal/procctl/procctl.go
Normal file
581
internal/procctl/procctl.go
Normal file
|
|
@ -0,0 +1,581 @@
|
||||||
|
// Package procctl mirrors the process-management half of tui-panel/
|
||||||
|
// server-panel.py (git pull, go build, launch/stop, and the shared
|
||||||
|
// .server_panel.json PID state file) so the admin web panel can offer the
|
||||||
|
// same Restart/Update actions the TUI already has, without requiring an
|
||||||
|
// operator to SSH in and use the TUI for that specifically.
|
||||||
|
//
|
||||||
|
// Restart/Update never restart the admin binary themselves (the process
|
||||||
|
// this code typically runs inside, when called from cmd/telesrv-admin) --
|
||||||
|
// self-restarting mid-HTTP-request is a materially different, riskier
|
||||||
|
// problem (dropped response, no clean signal to the caller that it actually
|
||||||
|
// completed) than the TUI's case, where a human is watching an interactive
|
||||||
|
// session and re-exec is transparent. Instead they set
|
||||||
|
// State.PendingAdminRestart and let the *next* owpengram-server process
|
||||||
|
// pick it up via HandlePendingAdminRestart once it's confirmed serving
|
||||||
|
// (cmd/telesrv/main.go's OnServing hook) -- that process is unrelated to
|
||||||
|
// whatever admin panel is currently running, so it can safely kill the old
|
||||||
|
// admin PID and launch a new one with none of the self-restart risk.
|
||||||
|
package procctl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const stateFileName = ".server_panel.json"
|
||||||
|
|
||||||
|
// Manager operates on one repo checkout (Root), the same layout
|
||||||
|
// tui-panel/server-panel.py expects: bin/, logs/, .env, .env.example, and
|
||||||
|
// .server_panel.json at the root.
|
||||||
|
type Manager struct {
|
||||||
|
Root string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(root string) *Manager {
|
||||||
|
return &Manager{Root: root}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) serverExe() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return filepath.Join(m.Root, "bin", "owpengram-server.exe")
|
||||||
|
}
|
||||||
|
return filepath.Join(m.Root, "bin", "owpengram-server")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) adminExe() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return filepath.Join(m.Root, "bin", "owpengram-admin-panel.exe")
|
||||||
|
}
|
||||||
|
return filepath.Join(m.Root, "bin", "owpengram-admin-panel")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) serverLog() string { return filepath.Join(m.Root, "logs", "owpengram-server.log") }
|
||||||
|
func (m *Manager) adminLog() string {
|
||||||
|
return filepath.Join(m.Root, "logs", "owpengram-admin-panel.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- state file (shared with tui-panel/server-panel.py) --------------------
|
||||||
|
|
||||||
|
type State struct {
|
||||||
|
ServerPID int `json:"server_pid"`
|
||||||
|
AdminPID int `json:"admin_pid"`
|
||||||
|
DockerProject string `json:"docker_project"`
|
||||||
|
DockerPrefix string `json:"docker_prefix"`
|
||||||
|
// PendingAdminRestart is how Restart/Update ask the *next*
|
||||||
|
// owpengram-server process to bounce the admin panel for them, instead
|
||||||
|
// of the admin panel trying to restart itself mid-HTTP-request (see the
|
||||||
|
// package doc). Set here, consumed by HandlePendingAdminRestart at the
|
||||||
|
// new owpengram-server's startup. server-panel.py doesn't know this key
|
||||||
|
// exists -- its own save_state() overwrites the file with only its 4
|
||||||
|
// original fields, so a Stop/Start/Restart/Update run from the TUI in
|
||||||
|
// the narrow window before the flag is consumed will silently drop it.
|
||||||
|
// Rare, and the only consequence is the admin panel not restarting that
|
||||||
|
// one time -- not worth coordinating two processes' writes over.
|
||||||
|
PendingAdminRestart bool `json:"pending_admin_restart,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) loadState() State {
|
||||||
|
var st State
|
||||||
|
data, err := os.ReadFile(filepath.Join(m.Root, stateFileName))
|
||||||
|
if err != nil {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(data, &st)
|
||||||
|
if st.DockerProject == "" {
|
||||||
|
st.DockerProject = "owpengram"
|
||||||
|
}
|
||||||
|
if st.DockerPrefix == "" {
|
||||||
|
st.DockerPrefix = "owpengram"
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) saveState(st State) error {
|
||||||
|
data, err := json.Marshal(st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(filepath.Join(m.Root, stateFileName), data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status reports whether the server/admin PIDs recorded in the shared state
|
||||||
|
// file are still alive.
|
||||||
|
type Status struct {
|
||||||
|
ServerPID int
|
||||||
|
ServerAlive bool
|
||||||
|
AdminPID int
|
||||||
|
AdminAlive bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Status() Status {
|
||||||
|
st := m.loadState()
|
||||||
|
return Status{
|
||||||
|
ServerPID: st.ServerPID,
|
||||||
|
ServerAlive: pidAlive(st.ServerPID),
|
||||||
|
AdminPID: st.AdminPID,
|
||||||
|
AdminAlive: pidAlive(st.AdminPID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- process control ---------------------------------------------------
|
||||||
|
|
||||||
|
func pidAlive(pid int) bool {
|
||||||
|
if pid <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
out, err := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid)).Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(string(out), strconv.Itoa(pid))
|
||||||
|
}
|
||||||
|
return exec.Command("kill", "-0", strconv.Itoa(pid)).Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// killPID mirrors kill_pid() in server-panel.py, MINUS its "/T" tree-kill on
|
||||||
|
// Windows -- deliberately different here, not an oversight. server-panel.py
|
||||||
|
// is always the common parent of both the server and admin processes, so
|
||||||
|
// tree-killing one PID never touches the other. This package's callers are
|
||||||
|
// not: HandlePendingAdminRestart runs *inside* the freshly launched
|
||||||
|
// owpengram-server, which was itself spawned as a child of the *old*
|
||||||
|
// owpengram-admin-panel process by the Restart/Update call that got it
|
||||||
|
// here. Killing that old admin PID with "/T" would tree-kill its entire
|
||||||
|
// descendant chain -- including this very owpengram-server process, since
|
||||||
|
// it's a child of the PID being killed. Windows' taskkill walks that chain
|
||||||
|
// by recorded parent-PID regardless of any process-group flags on launch,
|
||||||
|
// so the only reliable fix is to never tree-kill here: exact-PID kill only,
|
||||||
|
// since every process this package launches is started directly via
|
||||||
|
// exec.Command (no intermediate shell wrapper), so there's no wrapper-spawned
|
||||||
|
// grandchild "/T" would need to catch anyway.
|
||||||
|
func killPID(pid int) {
|
||||||
|
if pid <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
_ = exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/F").Run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = exec.Command("kill", "-TERM", strconv.Itoa(pid)).Run()
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
_ = exec.Command("kill", "-KILL", strconv.Itoa(pid)).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// launch starts exePath detached, cwd=Root, stdout/stderr appended to
|
||||||
|
// logPath, and returns its PID. Unlike the Python TUI this does not set a
|
||||||
|
// new session/process group (that needs OS-specific SysProcAttr) -- started
|
||||||
|
// via Start() (not Run()), the child outlives this function's return either
|
||||||
|
// way, which is all a request/response HTTP handler needs.
|
||||||
|
func (m *Manager) launch(exePath, logPath string) (int, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
|
||||||
|
return 0, fmt.Errorf("mkdir logs: %w", err)
|
||||||
|
}
|
||||||
|
logf, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("open log: %w", err)
|
||||||
|
}
|
||||||
|
defer logf.Close()
|
||||||
|
cmd := exec.Command(exePath)
|
||||||
|
cmd.Dir = m.Root
|
||||||
|
cmd.Stdout = logf
|
||||||
|
cmd.Stderr = logf
|
||||||
|
cmd.Stdin = nil
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return 0, fmt.Errorf("start %s: %w", exePath, err)
|
||||||
|
}
|
||||||
|
go func() { _ = cmd.Wait() }() // reap so it doesn't linger as a zombie
|
||||||
|
return cmd.Process.Pid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Docker infrastructure (Postgres/Redis/MinIO) -------------------------
|
||||||
|
|
||||||
|
const (
|
||||||
|
postgresWaitTimeout = 60 * time.Second
|
||||||
|
postgresWaitInterval = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// ensureDocker mirrors server-panel.py's START_STEPS "docker" + "postgres"
|
||||||
|
// steps -- `docker compose up -d` then wait for Postgres to answer
|
||||||
|
// pg_isready. Restart/Update run this every time, same as the TUI: it's a
|
||||||
|
// no-op when the containers are already up (compose up -d on a running
|
||||||
|
// stack just confirms state), but skipping it entirely was the actual bug
|
||||||
|
// report this addresses -- a Restart/Update landing while Postgres/Redis/
|
||||||
|
// MinIO are down (host reboot, containers manually stopped, etc.) would
|
||||||
|
// otherwise relaunch owpengram-server straight into a DB-connect failure
|
||||||
|
// with no clear signal why, instead of surfacing "Postgres not ready" here.
|
||||||
|
func (m *Manager) ensureDocker(ctx context.Context, st State) (string, error) {
|
||||||
|
composeFile := filepath.Join(m.Root, "deploy", "docker-compose.yml")
|
||||||
|
if _, err := os.Stat(composeFile); os.IsNotExist(err) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "docker", "compose", "-f", composeFile, "up", "-d")
|
||||||
|
cmd.Dir = m.Root
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"TELESRV_DOCKER_PROJECT="+st.DockerProject,
|
||||||
|
"TELESRV_DOCKER_PREFIX="+st.DockerPrefix,
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
log := "$ docker compose up -d\n" + string(out)
|
||||||
|
if err != nil {
|
||||||
|
return log, fmt.Errorf("docker compose up failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(postgresWaitTimeout)
|
||||||
|
for {
|
||||||
|
pgCmd := exec.CommandContext(ctx, "docker", "exec", st.DockerPrefix+"-postgres", "pg_isready", "-U", "telesrv", "-d", "telesrv")
|
||||||
|
if pgCmd.Run() == nil {
|
||||||
|
return log + "\nPostgreSQL ready\n", nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return log, fmt.Errorf("PostgreSQL not ready after %s", postgresWaitTimeout)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return log, ctx.Err()
|
||||||
|
case <-time.After(postgresWaitInterval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- build steps ---------------------------------------------------------
|
||||||
|
|
||||||
|
// GitPull runs `git pull --ff-only`, deliberately never a real merge -- see
|
||||||
|
// the identical reasoning in server-panel.py's git_pull().
|
||||||
|
func (m *Manager) GitPull(ctx context.Context) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "pull", "--ff-only")
|
||||||
|
cmd.Dir = m.Root
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
log := "$ git pull --ff-only\n" + string(out)
|
||||||
|
return log, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildServer builds only bin/owpengram-server.
|
||||||
|
func (m *Manager) buildServer(ctx context.Context) (string, error) {
|
||||||
|
return m.goBuild(ctx, m.serverExe(), "./cmd/telesrv")
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildBoth builds bin/owpengram-server and bin/owpengram-admin-panel, like
|
||||||
|
// server-panel.py's build(). Used by Update, which leaves a fresh admin
|
||||||
|
// binary on disk even though it doesn't self-restart into it (see package
|
||||||
|
// doc).
|
||||||
|
func (m *Manager) buildBoth(ctx context.Context) (string, error) {
|
||||||
|
serverLog, err := m.buildServer(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return serverLog, err
|
||||||
|
}
|
||||||
|
adminLog, err := m.goBuild(ctx, m.adminExe(), "./cmd/telesrv-admin")
|
||||||
|
return serverLog + "\n" + adminLog, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) goBuild(ctx context.Context, outPath, pkg string) (string, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("mkdir bin: %w", err)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, "go", "build", "-o", outPath, pkg)
|
||||||
|
cmd.Dir = m.Root
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
return fmt.Sprintf("$ go build -o %s %s\n%s", filepath.Base(outPath), pkg, string(out)), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- high-level actions ----------------------------------------------------
|
||||||
|
|
||||||
|
// Restart rebuilds and relaunches only bin/owpengram-server (the MTProto
|
||||||
|
// data-plane process) -- never the admin binary currently handling this
|
||||||
|
// request. Returns a combined build/relaunch log for the admin UI.
|
||||||
|
func (m *Manager) Restart(ctx context.Context) (string, error) {
|
||||||
|
st := m.loadState()
|
||||||
|
if pidAlive(st.ServerPID) {
|
||||||
|
killPID(st.ServerPID)
|
||||||
|
}
|
||||||
|
dockerLog, err := m.ensureDocker(ctx, st)
|
||||||
|
if err != nil {
|
||||||
|
return dockerLog, err
|
||||||
|
}
|
||||||
|
buildLog, err := m.buildServer(ctx)
|
||||||
|
fullLog := dockerLog + "\n" + buildLog
|
||||||
|
if err != nil {
|
||||||
|
return fullLog, fmt.Errorf("build failed: %w", err)
|
||||||
|
}
|
||||||
|
pid, err := m.launch(m.serverExe(), m.serverLog())
|
||||||
|
if err != nil {
|
||||||
|
return fullLog, fmt.Errorf("launch failed: %w", err)
|
||||||
|
}
|
||||||
|
st.ServerPID = pid
|
||||||
|
// Ask the process we just launched to bounce the admin panel for us
|
||||||
|
// once it's up -- see HandlePendingAdminRestart's doc comment for why
|
||||||
|
// that's the safe side of this handoff to do it from.
|
||||||
|
st.PendingAdminRestart = true
|
||||||
|
if err := m.saveState(st); err != nil {
|
||||||
|
return fullLog, fmt.Errorf("save state: %w", err)
|
||||||
|
}
|
||||||
|
return fullLog + fmt.Sprintf("\nowpengram-server relaunched, pid=%d. Admin panel will restart shortly.\n", pid), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update runs git pull, ensures Docker infrastructure is up, rebuilds both
|
||||||
|
// binaries, then does the same server-only relaunch as Restart -- including
|
||||||
|
// asking that new process to bounce the admin panel too, now onto its
|
||||||
|
// freshly built binary.
|
||||||
|
func (m *Manager) Update(ctx context.Context) (string, error) {
|
||||||
|
pullLog, err := m.GitPull(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return pullLog, fmt.Errorf("git pull failed: %w", err)
|
||||||
|
}
|
||||||
|
st := m.loadState()
|
||||||
|
if pidAlive(st.ServerPID) {
|
||||||
|
killPID(st.ServerPID)
|
||||||
|
}
|
||||||
|
dockerLog, err := m.ensureDocker(ctx, st)
|
||||||
|
fullLog := pullLog + "\n" + dockerLog
|
||||||
|
if err != nil {
|
||||||
|
return fullLog, err
|
||||||
|
}
|
||||||
|
buildLog, err := m.buildBoth(ctx)
|
||||||
|
fullLog = fullLog + "\n" + buildLog
|
||||||
|
if err != nil {
|
||||||
|
return fullLog, fmt.Errorf("build failed: %w", err)
|
||||||
|
}
|
||||||
|
pid, err := m.launch(m.serverExe(), m.serverLog())
|
||||||
|
if err != nil {
|
||||||
|
return fullLog, fmt.Errorf("launch failed: %w", err)
|
||||||
|
}
|
||||||
|
st.ServerPID = pid
|
||||||
|
st.PendingAdminRestart = true
|
||||||
|
if err := m.saveState(st); err != nil {
|
||||||
|
return fullLog, fmt.Errorf("save state: %w", err)
|
||||||
|
}
|
||||||
|
return fullLog + fmt.Sprintf("\nowpengram-server relaunched, pid=%d. Admin panel will restart shortly onto its freshly built binary.\n", pid), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlePendingAdminRestart is called once by owpengram-server itself, right
|
||||||
|
// after it confirms it's up and serving (see cmd/telesrv/main.go's
|
||||||
|
// OnServing hook) -- never by the admin panel on itself. That ordering is
|
||||||
|
// the whole point: by the time this runs, the *new* owpengram-server
|
||||||
|
// process already exists and is unrelated to whatever admin panel process
|
||||||
|
// is currently running, so killing the old admin PID and launching a new
|
||||||
|
// one here carries none of the risk self-restarting mid-HTTP-request would
|
||||||
|
// (see the package doc). A no-op when no restart was requested.
|
||||||
|
func (m *Manager) HandlePendingAdminRestart(ctx context.Context) (bool, error) {
|
||||||
|
st := m.loadState()
|
||||||
|
if !st.PendingAdminRestart {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if pidAlive(st.AdminPID) {
|
||||||
|
killPID(st.AdminPID)
|
||||||
|
}
|
||||||
|
pid, err := m.launch(m.adminExe(), m.adminLog())
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("launch admin panel: %w", err)
|
||||||
|
}
|
||||||
|
st.AdminPID = pid
|
||||||
|
st.PendingAdminRestart = false
|
||||||
|
if err := m.saveState(st); err != nil {
|
||||||
|
return true, fmt.Errorf("save state: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- .env.example / .env editing -------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
activeFieldRe = regexp.MustCompile(`^(TELESRV_[A-Z0-9_]+)=(.*)$`)
|
||||||
|
commentedFieldRe = regexp.MustCompile(`^#\s*(TELESRV_[A-Z0-9_]+)=(.*)$`)
|
||||||
|
sensitiveKeyRe = regexp.MustCompile(`(PASSWORD|SECRET|_TOKEN|API_KEY)`)
|
||||||
|
groupHeaderRe = regexp.MustCompile(`^##\s*(.+?)\s*--\s*(.+)$`)
|
||||||
|
sectionBreakRe = regexp.MustCompile(`^#\s*={10,}\s*$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type EnvField struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
DefaultValue string `json:"default_value"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
EnabledByDefault bool `json:"enabled_by_default"`
|
||||||
|
Sensitive bool `json:"sensitive"`
|
||||||
|
// Value is the field's current effective value: from .env when set,
|
||||||
|
// otherwise DefaultValue (only when EnabledByDefault), else empty --
|
||||||
|
// exactly current_env_values()'s semantics in server-panel.py.
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnvGroup struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Fields []EnvField `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadEnvGroups parses .env.example into the same panel-visible groups
|
||||||
|
// server-panel.py's parse_env_template() does (identical header/format
|
||||||
|
// rules -- see that function's docstring), then fills in each field's
|
||||||
|
// current effective value from .env.
|
||||||
|
func (m *Manager) ReadEnvGroups() ([]EnvGroup, error) {
|
||||||
|
tmplPath := filepath.Join(m.Root, ".env.example")
|
||||||
|
tmplData, err := os.ReadFile(tmplPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read .env.example: %w", err)
|
||||||
|
}
|
||||||
|
envValues, err := m.readEnvFile()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var groups []EnvGroup
|
||||||
|
var current *EnvGroup
|
||||||
|
var pending []string
|
||||||
|
inCommentRun := false
|
||||||
|
seen := map[string]bool{}
|
||||||
|
|
||||||
|
appendField := func(key, defaultValue, description string, enabledByDefault bool) {
|
||||||
|
if current == nil || seen[key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
value, has := envValues[key]
|
||||||
|
if !has {
|
||||||
|
if enabledByDefault {
|
||||||
|
value = defaultValue
|
||||||
|
} else {
|
||||||
|
value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.Fields = append(current.Fields, EnvField{
|
||||||
|
Key: key,
|
||||||
|
DefaultValue: defaultValue,
|
||||||
|
Description: description,
|
||||||
|
EnabledByDefault: enabledByDefault,
|
||||||
|
Sensitive: sensitiveKeyRe.MatchString(key),
|
||||||
|
Value: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range strings.Split(string(tmplData), "\n") {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
if line == "" {
|
||||||
|
pending = nil
|
||||||
|
inCommentRun = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if h := groupHeaderRe.FindStringSubmatch(line); h != nil {
|
||||||
|
groups = append(groups, EnvGroup{Title: strings.TrimSpace(h[1]), Description: strings.TrimSpace(h[2])})
|
||||||
|
current = &groups[len(groups)-1]
|
||||||
|
pending = nil
|
||||||
|
inCommentRun = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sectionBreakRe.MatchString(line) {
|
||||||
|
current = nil
|
||||||
|
pending = nil
|
||||||
|
inCommentRun = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a := activeFieldRe.FindStringSubmatch(line); a != nil {
|
||||||
|
appendField(a[1], a[2], strings.Join(pending, " "), true)
|
||||||
|
inCommentRun = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "#") {
|
||||||
|
if c := commentedFieldRe.FindStringSubmatch(line); c != nil {
|
||||||
|
appendField(c[1], c[2], strings.Join(pending, " "), false)
|
||||||
|
inCommentRun = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(strings.TrimLeft(line, "#"))
|
||||||
|
if inCommentRun {
|
||||||
|
pending = append(pending, text)
|
||||||
|
} else {
|
||||||
|
pending = []string{text}
|
||||||
|
}
|
||||||
|
inCommentRun = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inCommentRun = false
|
||||||
|
}
|
||||||
|
|
||||||
|
out := groups[:0]
|
||||||
|
for _, g := range groups {
|
||||||
|
if len(g.Fields) > 0 {
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) readEnvFile() (map[string]string, error) {
|
||||||
|
values := map[string]string{}
|
||||||
|
data, err := os.ReadFile(filepath.Join(m.Root, ".env"))
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read .env: %w", err)
|
||||||
|
}
|
||||||
|
for _, raw := range strings.Split(string(data), "\n") {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := strings.IndexByte(line, '=')
|
||||||
|
if idx <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values[line[:idx]] = strings.TrimSpace(line[idx+1:])
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteEnvValues rewrites .env from .env.example's exact text, substituting
|
||||||
|
// each known key's value in place -- see save_env()'s docstring in
|
||||||
|
// server-panel.py for why this (not a fresh key=value dump) is what
|
||||||
|
// preserves comments/layout. Only keys present in values are touched; a
|
||||||
|
// template-commented optional field is uncommented when given a non-empty
|
||||||
|
// value and left as-is when given an empty one.
|
||||||
|
func (m *Manager) WriteEnvValues(values map[string]string) error {
|
||||||
|
tmplPath := filepath.Join(m.Root, ".env.example")
|
||||||
|
tmplData, err := os.ReadFile(tmplPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read .env.example: %w", err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(tmplData), "\n")
|
||||||
|
// Split() on a trailing "\n" leaves one empty trailing element; drop it
|
||||||
|
// so the join below doesn't add a spurious blank line before the final
|
||||||
|
// newline this function appends anyway.
|
||||||
|
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||||
|
lines = lines[:len(lines)-1]
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(lines))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, raw := range lines {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
if a := activeFieldRe.FindStringSubmatch(line); a != nil {
|
||||||
|
if v, ok := values[a[1]]; ok && !seen[a[1]] {
|
||||||
|
seen[a[1]] = true
|
||||||
|
out = append(out, a[1]+"="+v)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c := commentedFieldRe.FindStringSubmatch(line); c != nil {
|
||||||
|
if v, ok := values[c[1]]; ok && !seen[c[1]] {
|
||||||
|
seen[c[1]] = true
|
||||||
|
if v != "" {
|
||||||
|
out = append(out, c[1]+"="+v)
|
||||||
|
} else {
|
||||||
|
out = append(out, raw)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, raw)
|
||||||
|
}
|
||||||
|
return os.WriteFile(filepath.Join(m.Root, ".env"), []byte(strings.Join(out, "\n")+"\n"), 0o644)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue