Ctrl K
ComunidadGuías…

internal/procctl/env.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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)
}