internal/procctl/env.go
Rama main · réplica de lectura del repositorio original.
// Package procctl provides the .env parsing and rewriting needed by the admin web
// panel's "Environment (.env)" editor. It implements only the subset this repo needs:
// reading editable field groups from .env.example (ReadEnvGroups) and writing values
// saved by the panel back into .env (WriteEnvValues) -- it does not include the heavier
// features of process management (start/stop/restart/update), PID state files, git
// operations or docker probing.
//
// The conventions match the scripts/panel: .env.example splits groups with `## Title --
// description` lines, breaks sections with `# ====...` lines, and the `#` comment block
// immediately preceding a field acts as its description; a `TELESRV_*` field line is
// either active (`K=V`) or commented out (`# K=V`). The foundation of writing back is
// "use .env.example as the base and replace the values of known keys in place", so
// comments, layout and untouched keys all survive -- never re-dump key/value pairs.
package procctl
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
var (
activeFieldRe = regexp.MustCompile(`^(TELESRV_[A-Z0-9_]+)=(.*)$`)
commentedFieldRe = regexp.MustCompile(`^#\s*(TELESRV_[A-Z0-9_]+)=(.*)$`)
sensitiveKeyRe = regexp.MustCompile(`(PASSWORD|SECRET|_TOKEN|API_KEY)`)
// sensitiveKeyExceptRe excludes keys where the sensitive-word match "SECRET" actually
// refers to Telegram's secret-chat feature rather than a credential (e.g.
// TELESRV_SECRET_CHAT_DELETE_FILE_AFTER_DOWNLOAD): there is nothing to mask there,
// it is just an ordinary boolean switch.
sensitiveKeyExceptRe = regexp.MustCompile(`SECRET_CHAT`)
groupHeaderRe = regexp.MustCompile(`^##\s*(.+?)\s*--\s*(.+)$`)
sectionBreakRe = regexp.MustCompile(`^#\s*={10,}\s*$`)
)
// EnvField is the presentation of a single editable environment variable in the panel:
// default value, description, whether it is enabled by default, whether it is sensitive
// (its value should be masked in the UI), and the currently effective value.
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 currently effective value: .env if it is set there, otherwise
// the field's default value (only when EnabledByDefault), and empty otherwise.
Value string `json:"value"`
}
// EnvGroup is a field group, corresponding to a `## Title -- description` block in
// .env.example.
type EnvGroup struct {
Title string `json:"title"`
Description string `json:"description"`
Fields []EnvField `json:"fields"`
}
// Manager works against the .env and .env.example under the repo root (Root).
type Manager struct {
Root string
}
func NewManager(root string) *Manager {
return &Manager{Root: root}
}
// ReadEnvGroups parses .env.example into the panel-visible groups (the header/format
// rules match server-panel.py's parse_env_template()), then back-fills each field's
// effective value from the current values in .env. A missing template file returns no
// groups (without an error).
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) && !sensitiveKeyExceptRe.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 writes back to .env using .env.example's text as the base and
// replacing the values of each known key in place -- so comments and layout survive.
// Only keys present in values are rewritten; every other key keeps its current .env
// value (only keys .env never set fall back to the template's own default) -- an
// earlier version fell back to the template default for any key absent from the save
// payload, so a save touching only one group silently wiped other customized settings
// (e.g. TELESRV_ADMIN_UI_PASSWORD). For an optional field commented out in the
// template: a non-empty value uncomments it and writes it, while an empty value leaves
// the commented line as-is.
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)
}
existing, err := m.readEnvFile()
if err != nil {
return err
}
lines := strings.Split(string(tmplData), "\n")
// Split() leaves an empty trailing element for a final "\n"; drop it so the join
// below doesn't add an extra blank line at the end (this function already appends a
// newline at the end).
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 && !seen[a[1]] {
if v, ok := values[a[1]]; ok {
seen[a[1]] = true
out = append(out, a[1]+"="+v)
continue
}
if v, ok := existing[a[1]]; ok {
seen[a[1]] = true
out = append(out, a[1]+"="+v)
continue
}
}
if c := commentedFieldRe.FindStringSubmatch(line); c != nil && !seen[c[1]] {
if v, ok := values[c[1]]; ok {
seen[c[1]] = true
if v != "" {
out = append(out, c[1]+"="+v)
} else {
out = append(out, raw)
}
continue
}
// An optional field enabled before: the template still has it commented, but
// the current .env already has an active line -- keep it enabled and reuse its
// current value.
if v, ok := existing[c[1]]; ok {
seen[c[1]] = true
out = append(out, c[1]+"="+v)
continue
}
}
out = append(out, raw)
}
return os.WriteFile(filepath.Join(m.Root, ".env"), []byte(strings.Join(out, "\n")+"\n"), 0o644)
}