mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
- Introduced a new field `custom_phone_number` in the CardPolicy model and database schema. - Updated the API to handle custom phone number input, including validation and normalization. - Modified the CardPolicyPanel component to allow users to set and save a custom phone number. - Enhanced the settings API to include the custom phone number in responses and updates. - Added tests to ensure the correct functionality of custom phone number handling. - Removed hardcoded environment variable for VOCAT_ADDR in service files.
732 lines
24 KiB
Go
732 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/term"
|
|
|
|
"vocat/internal/auth"
|
|
"vocat/internal/config"
|
|
"vocat/internal/store"
|
|
"vocat/internal/update"
|
|
)
|
|
|
|
// envFilePath is the systemd EnvironmentFile that carries VOCAT_ADMIN_PASSWORD.
|
|
// EnsureAdmin reseeds the DB from it on every start, so change-password must
|
|
// rewrite it or the next restart reverts the password.
|
|
const envFilePath = "/etc/vocat/env"
|
|
|
|
// legacyEnvFilePath was used by the standalone deploy/vocat.service. Keep it
|
|
// discoverable so the menu works on installations made before the installer
|
|
// and service template converged on /etc/vocat/env.
|
|
const legacyEnvFilePath = "/etc/vocat/vocat.env"
|
|
|
|
const systemdUnitPath = "/etc/systemd/system/vocat.service"
|
|
|
|
// defaultDatabasePath is the install-default SQLite location written into the
|
|
// systemd unit by scripts/install.sh. Used only when VOCAT_DATABASE_PATH is not
|
|
// already set in the operator's environment.
|
|
const defaultDatabasePath = "/opt/vocat/data/vocat.db"
|
|
|
|
// uiPreferencesSettingKey is the same app_settings key the Web UI's
|
|
// /api/settings/preferences handler reads and writes (see general_api.go). The
|
|
// menu toggles language through it so a single preference is shared with the
|
|
// SPA and the backend i18n layer.
|
|
const uiPreferencesSettingKey = "ui.preferences"
|
|
|
|
// loadMenuEnv ensures the menu reaches the production config that systemd
|
|
// would otherwise inject. When an operator runs `sudo vocat` on the host, the
|
|
// shell has not sourced /etc/vocat/env (a systemd EnvironmentFile, not a shell
|
|
// rc) and VOCAT_DATABASE_PATH is unset, so config.Load() would resolve a
|
|
// CWD-relative ./data/vocat.db — a different, empty database than
|
|
// /opt/vocat/data/vocat.db the service uses. This loads the installed env file
|
|
// for VOCAT_ADMIN_PASSWORD and pins VOCAT_DATABASE_PATH to the install default,
|
|
// without overriding any value the operator already exported.
|
|
func loadMenuEnv() {
|
|
if _, ok := os.LookupEnv("VOCAT_DATABASE_PATH"); !ok {
|
|
_ = os.Setenv("VOCAT_DATABASE_PATH", defaultDatabasePath)
|
|
}
|
|
if data, err := os.ReadFile(menuEnvFilePath()); err == nil {
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
eq := strings.IndexByte(line, '=')
|
|
if eq < 0 {
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(line[:eq])
|
|
val := strings.TrimSpace(line[eq+1:])
|
|
if _, ok := os.LookupEnv(key); !ok {
|
|
_ = os.Setenv(key, val)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func menuEnvFilePath() string {
|
|
if _, err := os.Stat(envFilePath); err == nil {
|
|
return envFilePath
|
|
}
|
|
if _, err := os.Stat(legacyEnvFilePath); err == nil {
|
|
return legacyEnvFilePath
|
|
}
|
|
return envFilePath
|
|
}
|
|
|
|
// runMenu is the interactive lifecycle menu: toggle language, change password,
|
|
// change the Web listener port, restart the systemd unit, self-update, or fully
|
|
// uninstall vocat. It must run as root on the host (needs systemctl + the 0600
|
|
// env file). Docker deployments do not use it.
|
|
func runMenu(logger *slog.Logger) error {
|
|
if os.Geteuid() != 0 {
|
|
return errors.New("vocat menu must run as root (needs systemctl and /etc/vocat/env)")
|
|
}
|
|
fd := int(os.Stdin.Fd())
|
|
if !term.IsTerminal(fd) {
|
|
return errors.New("vocat menu requires an interactive terminal")
|
|
}
|
|
|
|
loadMenuEnv()
|
|
|
|
lang, langErr := loadMenuLanguage()
|
|
menu := newMenu(lang)
|
|
if langErr != nil {
|
|
logger.Warn("menu: load language preference failed; defaulting to English", "error", langErr)
|
|
}
|
|
reader := bufio.NewReader(os.Stdin)
|
|
|
|
for {
|
|
fmt.Println()
|
|
fmt.Println(menu.title())
|
|
for _, opt := range menu.options() {
|
|
fmt.Printf(" %s\n", opt)
|
|
}
|
|
fmt.Print(menu.prompt())
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return fmt.Errorf("read menu choice: %w", err)
|
|
}
|
|
choice := strings.TrimSpace(line)
|
|
switch choice {
|
|
case "1":
|
|
if err := menuToggleLanguage(menu, logger); err != nil {
|
|
fmt.Println(menu.errorPrefix(err))
|
|
}
|
|
case "2":
|
|
if err := menuChangePassword(reader, menu, logger); err != nil {
|
|
fmt.Println(menu.errorPrefix(err))
|
|
}
|
|
case "3":
|
|
if err := menuChangeWebPort(reader, menu); err != nil {
|
|
fmt.Println(menu.errorPrefix(err))
|
|
}
|
|
case "4":
|
|
if err := menuRestart(menu); err != nil {
|
|
fmt.Println(menu.errorPrefix(err))
|
|
}
|
|
case "5":
|
|
if err := menuUpdate(menu, logger); err != nil {
|
|
fmt.Println(menu.errorPrefix(err))
|
|
}
|
|
case "0":
|
|
if err := menuUninstall(reader, menu); err != nil {
|
|
fmt.Println(menu.errorPrefix(err))
|
|
} else {
|
|
return nil
|
|
}
|
|
default:
|
|
fmt.Println(menu.invalid())
|
|
}
|
|
}
|
|
}
|
|
|
|
// loadMenuLanguage reads the persisted UI preference (the same ui.preferences
|
|
// app_setting the Web UI writes) and returns "zh" or "en". When no record
|
|
// exists yet it returns "en", matching the Web default in writeUIPreferences.
|
|
// Any failure is surfaced to the caller, which logs a warning and keeps the
|
|
// default rather than blocking the menu.
|
|
func loadMenuLanguage() (string, error) {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return "en", fmt.Errorf("%w: %v", errMenuConfig, err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
database, err := store.Open(ctx, cfg.DatabasePath)
|
|
if err != nil {
|
|
return "en", fmt.Errorf("%w: %v", errMenuStore, err)
|
|
}
|
|
defer database.Close()
|
|
|
|
setting, err := database.AppSetting(ctx, uiPreferencesSettingKey)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return "en", nil
|
|
}
|
|
return "en", fmt.Errorf("%w: %v", errMenuStore, err)
|
|
}
|
|
var prefs struct {
|
|
Language string `json:"language"`
|
|
}
|
|
if err := json.Unmarshal(setting.Value, &prefs); err != nil {
|
|
return "en", nil
|
|
}
|
|
if prefs.Language == "zh" {
|
|
return "zh", nil
|
|
}
|
|
return "en", nil
|
|
}
|
|
|
|
func menuChangePassword(reader *bufio.Reader, m *menu, logger *slog.Logger) error {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
database, err := store.Open(ctx, cfg.DatabasePath)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", errMenuStore, err)
|
|
}
|
|
defer database.Close()
|
|
|
|
authService, err := auth.New(database, auth.Options{SessionTTL: cfg.SessionTTL})
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", errMenuAuth, err)
|
|
}
|
|
|
|
fmt.Print(m.currentPassword())
|
|
currentPw, err := readPasswordMasked()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Print(m.newPassword())
|
|
newPw, err := readPasswordMasked()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Print(m.confirmPassword())
|
|
confirmPw, err := readPasswordMasked()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println()
|
|
if newPw != confirmPw {
|
|
return errPasswordsDiffer
|
|
}
|
|
if err := authService.ChangePassword(ctx, cfg.AdminUsername, currentPw, newPw); err != nil {
|
|
if errors.Is(err, auth.ErrInvalidCredentials) {
|
|
return errCurrentWrong
|
|
}
|
|
return fmt.Errorf("%w: %v", errMenuAuth, err)
|
|
}
|
|
// Persist the new plaintext to the env file so the next EnsureAdmin (on
|
|
// restart) agrees with the hash we just wrote to the DB. Without this the
|
|
// restart reverts the password to whatever the env file still holds.
|
|
if err := rewriteEnvPassword(newPw); err != nil {
|
|
logger.Error("menu: password changed in DB but env file rewrite failed; restart will revert", "error", err)
|
|
return fmt.Errorf("%w: %v", errMenuEnvWrite, err)
|
|
}
|
|
fmt.Println(m.passwordChanged())
|
|
return nil
|
|
}
|
|
|
|
// readPasswordMasked reads a password with echo disabled. term.ReadPassword
|
|
// does not return the trailing newline, so we print one for a clean prompt.
|
|
func readPasswordMasked() (string, error) {
|
|
fd := int(os.Stdin.Fd())
|
|
bytes, err := term.ReadPassword(fd)
|
|
fmt.Println()
|
|
if err != nil {
|
|
return "", fmt.Errorf("read password: %w", err)
|
|
}
|
|
return string(bytes), nil
|
|
}
|
|
|
|
// rewriteEnvPassword replaces (or appends) the VOCAT_ADMIN_PASSWORD line in the
|
|
// systemd EnvironmentFile and keeps the file 0600. The replacement is atomic:
|
|
// the temp file lives in the same directory so os.Rename stays on one
|
|
// filesystem.
|
|
func rewriteEnvPassword(newPassword string) error {
|
|
return rewriteEnvValue(menuEnvFilePath(), "VOCAT_ADMIN_PASSWORD", newPassword)
|
|
}
|
|
|
|
// rewriteEnvValue replaces or appends one systemd EnvironmentFile value. The
|
|
// write is atomic and rejects line breaks so one setting cannot inject another.
|
|
func rewriteEnvValue(path, name, value string) error {
|
|
if name == "" || strings.ContainsAny(name, "=\r\n\x00") || strings.ContainsAny(value, "\r\n\x00") {
|
|
return errors.New("invalid environment setting")
|
|
}
|
|
key := name + "="
|
|
var lines []string
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
lines = strings.Split(string(data), "\n")
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
|
|
replaced := false
|
|
for i, line := range lines {
|
|
if strings.HasPrefix(line, key) {
|
|
lines[i] = key + value
|
|
replaced = true
|
|
break
|
|
}
|
|
}
|
|
if !replaced {
|
|
lines = append(lines, key+value)
|
|
}
|
|
content := strings.Join(lines, "\n")
|
|
if !strings.HasSuffix(content, "\n") {
|
|
content += "\n"
|
|
}
|
|
return writeEnvFileAtomic(path, []byte(content))
|
|
}
|
|
|
|
func writeEnvFileAtomic(path string, content []byte) error {
|
|
dirIndex := strings.LastIndexAny(path, "/\\")
|
|
if dirIndex < 0 {
|
|
return errors.New("environment file path has no directory")
|
|
}
|
|
dir := path[:dirIndex]
|
|
tmp, err := os.CreateTemp(dir, ".vocat-env-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer os.Remove(tmpName)
|
|
if _, err := tmp.Write(content); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Chmod(0o600); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmpName, path)
|
|
}
|
|
|
|
func menuChangeWebPort(reader *bufio.Reader, m *menu) error {
|
|
if _, err := exec.LookPath("systemctl"); err != nil {
|
|
return errNoSystemctl
|
|
}
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
|
}
|
|
_, currentPortText, err := net.SplitHostPort(strings.TrimSpace(cfg.Address))
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
|
}
|
|
fmt.Println(m.currentWebAddress(cfg.Address))
|
|
fmt.Println(m.reverseProxyNotice())
|
|
fmt.Print(m.newWebPort(currentPortText))
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return fmt.Errorf("read Web port: %w", err)
|
|
}
|
|
portText := strings.TrimSpace(line)
|
|
if portText == "" {
|
|
fmt.Println(m.webPortCancelled())
|
|
return nil
|
|
}
|
|
newAddress, newPort, err := webAddressWithPort(cfg.Address, portText)
|
|
if err != nil {
|
|
return errInvalidWebPort
|
|
}
|
|
currentPort, _ := strconv.Atoi(currentPortText)
|
|
if newPort == currentPort {
|
|
fmt.Println(m.webPortUnchanged())
|
|
return nil
|
|
}
|
|
|
|
listener, err := net.Listen("tcp", newAddress)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", errWebPortUnavailable, err)
|
|
}
|
|
_ = listener.Close()
|
|
|
|
environmentPath := menuEnvFilePath()
|
|
original, readErr := os.ReadFile(environmentPath)
|
|
originalExisted := readErr == nil
|
|
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
|
return fmt.Errorf("%w: %v", errMenuPortWrite, readErr)
|
|
}
|
|
if err := rewriteEnvValue(environmentPath, "VOCAT_ADDR", newAddress); err != nil {
|
|
return fmt.Errorf("%w: %v", errMenuPortWrite, err)
|
|
}
|
|
if err := restartVocatService(); err != nil {
|
|
rollbackErr := restoreMenuEnvFile(environmentPath, original, originalExisted)
|
|
_ = restartVocatService()
|
|
if rollbackErr != nil {
|
|
return fmt.Errorf("%w: %v; rollback failed: %v", errRestartFailed, err, rollbackErr)
|
|
}
|
|
return fmt.Errorf("%w: %v", errRestartFailed, err)
|
|
}
|
|
if err := waitForWebListener(newAddress, 5*time.Second); err != nil {
|
|
rollbackErr := restoreMenuEnvFile(environmentPath, original, originalExisted)
|
|
_ = restartVocatService()
|
|
if rollbackErr != nil {
|
|
return fmt.Errorf("%w: %v; rollback failed: %v", errRestartFailed, err, rollbackErr)
|
|
}
|
|
return fmt.Errorf("%w: %v", errRestartFailed, err)
|
|
}
|
|
_ = os.Setenv("VOCAT_ADDR", newAddress)
|
|
fmt.Println(m.webPortChanged(newAddress))
|
|
return nil
|
|
}
|
|
|
|
func webAddressWithPort(address, portText string) (string, int, error) {
|
|
host, _, err := net.SplitHostPort(strings.TrimSpace(address))
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
port, err := strconv.Atoi(strings.TrimSpace(portText))
|
|
if err != nil || port < 1 || port > 65535 {
|
|
return "", 0, errInvalidWebPort
|
|
}
|
|
return net.JoinHostPort(host, strconv.Itoa(port)), port, nil
|
|
}
|
|
|
|
func waitForWebListener(address string, timeout time.Duration) error {
|
|
host, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch host {
|
|
case "", "0.0.0.0":
|
|
host = "127.0.0.1"
|
|
case "::":
|
|
host = "::1"
|
|
}
|
|
target := net.JoinHostPort(host, port)
|
|
deadline := time.Now().Add(timeout)
|
|
var lastErr error
|
|
for time.Now().Before(deadline) {
|
|
connection, dialErr := net.DialTimeout("tcp", target, 500*time.Millisecond)
|
|
if dialErr == nil {
|
|
_ = connection.Close()
|
|
return nil
|
|
}
|
|
lastErr = dialErr
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
return fmt.Errorf("Web listener %s did not become reachable: %w", target, lastErr)
|
|
}
|
|
|
|
func restoreMenuEnvFile(path string, content []byte, existed bool) error {
|
|
if existed {
|
|
return writeEnvFileAtomic(path, content)
|
|
}
|
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// menuToggleLanguage flips the persisted language preference between "zh" and
|
|
// "en" by writing the same ui.preferences app_setting the Web UI uses, then
|
|
// switches the menu's own language so the next prompt renders in the new
|
|
// language. The Web SPA picks up the change on its next preferences fetch; the
|
|
// menu never needs to call i18n.Set itself since it carries its own lang copy.
|
|
func menuToggleLanguage(m *menu, logger *slog.Logger) error {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
database, err := store.Open(ctx, cfg.DatabasePath)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", errMenuStore, err)
|
|
}
|
|
defer database.Close()
|
|
|
|
current := m.lang
|
|
next := "zh"
|
|
if current == "zh" {
|
|
next = "en"
|
|
}
|
|
payload, err := json.Marshal(map[string]string{"language": next})
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", errMenuStore, err)
|
|
}
|
|
if err := database.UpsertAppSetting(ctx, store.AppSetting{
|
|
Key: uiPreferencesSettingKey,
|
|
Value: payload,
|
|
}); err != nil {
|
|
return fmt.Errorf("%w: %v", errMenuStore, err)
|
|
}
|
|
m.lang = next
|
|
fmt.Println(m.languageSwitched())
|
|
return nil
|
|
}
|
|
|
|
func menuRestart(m *menu) error {
|
|
if err := restartVocatService(); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(m.restarted())
|
|
return nil
|
|
}
|
|
|
|
func restartVocatService() error {
|
|
if _, err := exec.LookPath("systemctl"); err != nil {
|
|
return errNoSystemctl
|
|
}
|
|
cmd := exec.Command("systemctl", "restart", "vocat")
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("%w: %s", errRestartFailed, strings.TrimSpace(string(out)))
|
|
}
|
|
if out, err := exec.Command("systemctl", "is-active", "--quiet", "vocat").CombinedOutput(); err != nil {
|
|
return fmt.Errorf("%w: service is not active: %s", errRestartFailed, strings.TrimSpace(string(out)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// menuUpdate delegates to the self-updater in internal/update. It resolves the
|
|
// repo the same way update.Run itself does ($VOCAT_REPO or the default) and lets
|
|
// that package handle the check/download/verify/replace/restart flow. The
|
|
// running menu process keeps the old binary until the operator exits; only the
|
|
// systemd service runs the new build after restartService.
|
|
func menuUpdate(m *menu, logger *slog.Logger) error {
|
|
repo := strings.TrimSpace(os.Getenv("VOCAT_REPO"))
|
|
if repo == "" {
|
|
repo = update.DefaultRepository
|
|
}
|
|
fmt.Println(m.updateChecking())
|
|
if err := update.Run(logger, []string{"--repo", repo}); err != nil {
|
|
return fmt.Errorf("%w: %v", errUpdateFailed, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// menuUninstall performs full removal: stop/disable the unit, delete the unit,
|
|
// remove /opt/vocat (binary + data + SQLite DB), remove the env file, reload
|
|
// systemd, and best-effort delete the vocat user.
|
|
func menuUninstall(reader *bufio.Reader, m *menu) error {
|
|
fmt.Println(m.uninstallWarn())
|
|
fmt.Print(m.uninstallConfirm())
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return fmt.Errorf("read confirmation: %w", err)
|
|
}
|
|
if strings.TrimSpace(line) != "yes" {
|
|
fmt.Println(m.uninstallCancelled())
|
|
return nil
|
|
}
|
|
|
|
runIgnore := func(name string, args ...string) {
|
|
_ = exec.Command(name, args...).Run()
|
|
}
|
|
runIgnore("systemctl", "stop", "vocat")
|
|
runIgnore("systemctl", "disable", "vocat")
|
|
_ = os.Remove(systemdUnitPath)
|
|
_ = os.RemoveAll("/opt/vocat")
|
|
_ = os.Remove(envFilePath)
|
|
_ = os.Remove(legacyEnvFilePath)
|
|
_ = os.Remove("/etc/vocat") // succeeds only when empty
|
|
runIgnore("systemctl", "daemon-reload")
|
|
runIgnore("userdel", "vocat")
|
|
|
|
fmt.Println(m.uninstalled())
|
|
return nil
|
|
}
|
|
|
|
// menu-local sentinel errors so callers can map them to localized messages.
|
|
var (
|
|
errCurrentWrong = errors.New("menu: current password is incorrect")
|
|
errPasswordsDiffer = errors.New("menu: passwords do not match")
|
|
errNoSystemctl = errors.New("menu: systemctl not found")
|
|
errRestartFailed = errors.New("menu: restart failed")
|
|
errUpdateFailed = errors.New("menu: update failed")
|
|
errMenuConfig = errors.New("menu: load configuration")
|
|
errMenuStore = errors.New("menu: open database")
|
|
errMenuAuth = errors.New("menu: auth service")
|
|
errMenuEnvWrite = errors.New("menu: write env file")
|
|
errMenuPortWrite = errors.New("menu: write Web port")
|
|
errInvalidWebPort = errors.New("menu: invalid Web port")
|
|
errWebPortUnavailable = errors.New("menu: Web port unavailable")
|
|
)
|
|
|
|
// ---- i18n ----
|
|
|
|
type menu struct{ lang string }
|
|
|
|
func newMenu(lang string) *menu { return &menu{lang: lang} }
|
|
|
|
// msg returns the localized string for a key. Each key carries [zh, en].
|
|
func (m *menu) msg(key string) string {
|
|
const zh, en = 0, 1
|
|
table := map[string][2]string{
|
|
"title": {"vocat 管理菜单", "vocat management menu"},
|
|
"opt_lang": {"1) 切换中英文", "1) Toggle language"},
|
|
"opt_change": {"2) 修改账号密码", "2) Change admin password"},
|
|
"opt_port": {"3) 修改 Web 监听端口", "3) Change Web listening port"},
|
|
"opt_restart": {"4) 重启软件", "4) Restart software"},
|
|
"opt_update": {"5) 更新软件", "5) Update software"},
|
|
"opt_uninstall": {"0) 卸载软件", "0) Uninstall software"},
|
|
"prompt": {"请选择: ", "Select: "},
|
|
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
|
|
"cur_pw": {"当前密码: ", "Current password: "},
|
|
"new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
|
|
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
|
|
"pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."},
|
|
"current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"},
|
|
"new_web_port": {"新端口 (1-65535,直接回车取消,当前 %s): ", "New port (1-65535, Enter to cancel, current %s): "},
|
|
"web_port_cancelled": {"已取消修改端口。", "Web port change cancelled."},
|
|
"web_port_unchanged": {"端口未改变。", "Web port is unchanged."},
|
|
"web_port_changed": {"Web 监听地址已改为 %s,软件已重启。", "Web listening address changed to %s; software restarted."},
|
|
"reverse_proxy_notice": {
|
|
"如使用 Nginx/Caddy 等反向代理,请同步修改其上游端口。",
|
|
"If you use Nginx, Caddy, or another reverse proxy, update its upstream port too.",
|
|
},
|
|
"lang_switched": {
|
|
"语言已切换。Web 界面下次刷新后同步。",
|
|
"Language switched. The web UI syncs on next refresh.",
|
|
},
|
|
"upd_checking": {"正在检查更新…", "Checking for updates…"},
|
|
"restarted": {"软件已重启。", "Software restarted."},
|
|
"uninstall_warn": {
|
|
"警告: 将删除程序、数据与配置,且不可恢复!",
|
|
"WARNING: removes the program, data and config. Irreversible!",
|
|
},
|
|
"uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "},
|
|
"uninstall_cancelled": {"已取消卸载。", "Uninstall cancelled."},
|
|
"uninstalled": {"vocat 已卸载。", "vocat uninstalled."},
|
|
}
|
|
entry, ok := table[key]
|
|
if !ok {
|
|
return key
|
|
}
|
|
if m.lang == "en" {
|
|
return entry[en]
|
|
}
|
|
return entry[zh]
|
|
}
|
|
|
|
func (m *menu) title() string { return m.msg("title") }
|
|
func (m *menu) prompt() string { return m.msg("prompt") }
|
|
func (m *menu) invalid() string { return m.msg("invalid") }
|
|
func (m *menu) currentPassword() string { return m.msg("cur_pw") }
|
|
func (m *menu) newPassword() string { return m.msg("new_pw") }
|
|
func (m *menu) confirmPassword() string { return m.msg("confirm_pw") }
|
|
func (m *menu) passwordChanged() string { return m.msg("pw_changed") }
|
|
func (m *menu) currentWebAddress(address string) string {
|
|
return fmt.Sprintf(m.msg("current_web_address"), address)
|
|
}
|
|
func (m *menu) newWebPort(port string) string { return fmt.Sprintf(m.msg("new_web_port"), port) }
|
|
func (m *menu) webPortCancelled() string { return m.msg("web_port_cancelled") }
|
|
func (m *menu) webPortUnchanged() string { return m.msg("web_port_unchanged") }
|
|
func (m *menu) webPortChanged(address string) string {
|
|
return fmt.Sprintf(m.msg("web_port_changed"), address)
|
|
}
|
|
func (m *menu) reverseProxyNotice() string { return m.msg("reverse_proxy_notice") }
|
|
func (m *menu) languageSwitched() string { return m.msg("lang_switched") }
|
|
func (m *menu) updateChecking() string { return m.msg("upd_checking") }
|
|
func (m *menu) restarted() string { return m.msg("restarted") }
|
|
func (m *menu) uninstallWarn() string { return m.msg("uninstall_warn") }
|
|
func (m *menu) uninstallConfirm() string { return m.msg("uninstall_confirm") }
|
|
func (m *menu) uninstallCancelled() string { return m.msg("uninstall_cancelled") }
|
|
func (m *menu) uninstalled() string { return m.msg("uninstalled") }
|
|
|
|
func (m *menu) options() []string {
|
|
return []string{
|
|
m.msg("opt_lang"),
|
|
m.msg("opt_change"),
|
|
m.msg("opt_port"),
|
|
m.msg("opt_restart"),
|
|
m.msg("opt_update"),
|
|
m.msg("opt_uninstall"),
|
|
}
|
|
}
|
|
|
|
func (m *menu) errorPrefix(err error) string {
|
|
switch {
|
|
case errors.Is(err, errCurrentWrong):
|
|
if m.lang == "en" {
|
|
return "Current password is incorrect."
|
|
}
|
|
return "当前密码不正确。"
|
|
case errors.Is(err, errPasswordsDiffer):
|
|
if m.lang == "en" {
|
|
return "Passwords do not match."
|
|
}
|
|
return "两次输入的密码不一致。"
|
|
case errors.Is(err, errNoSystemctl):
|
|
if m.lang == "en" {
|
|
return "systemctl not found."
|
|
}
|
|
return "未找到 systemctl。"
|
|
case errors.Is(err, errRestartFailed):
|
|
if m.lang == "en" {
|
|
return "Restart failed."
|
|
}
|
|
return "重启失败。"
|
|
case errors.Is(err, errUpdateFailed):
|
|
if m.lang == "en" {
|
|
return "Update failed."
|
|
}
|
|
return "更新失败。"
|
|
case errors.Is(err, errMenuConfig):
|
|
if m.lang == "en" {
|
|
return "Failed to load configuration."
|
|
}
|
|
return "加载配置失败。"
|
|
case errors.Is(err, errMenuStore):
|
|
if m.lang == "en" {
|
|
return "Failed to open the database."
|
|
}
|
|
return "打开数据库失败。"
|
|
case errors.Is(err, errMenuAuth):
|
|
if m.lang == "en" {
|
|
return "Auth service error."
|
|
}
|
|
return "认证服务错误。"
|
|
case errors.Is(err, errMenuEnvWrite):
|
|
if m.lang == "en" {
|
|
return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + menuEnvFilePath() + "."
|
|
}
|
|
return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + menuEnvFilePath() + "。"
|
|
case errors.Is(err, errInvalidWebPort):
|
|
if m.lang == "en" {
|
|
return "Invalid port. Enter a number from 1 to 65535."
|
|
}
|
|
return "端口无效,请输入 1 到 65535。"
|
|
case errors.Is(err, errWebPortUnavailable):
|
|
if m.lang == "en" {
|
|
return "The new Web port is unavailable or already in use."
|
|
}
|
|
return "新的 Web 端口不可用或已被占用。"
|
|
case errors.Is(err, errMenuPortWrite):
|
|
if m.lang == "en" {
|
|
return "Failed to save the Web listening port to " + menuEnvFilePath() + "."
|
|
}
|
|
return "无法将 Web 监听端口保存到 " + menuEnvFilePath() + "。"
|
|
default:
|
|
if m.lang == "en" {
|
|
return "Error: " + err.Error()
|
|
}
|
|
return "错误: " + err.Error()
|
|
}
|
|
}
|