mirror of
https://github.com/tnb-labs/panel.git
synced 2026-09-19 10:03:36 +08:00
- 依赖注入换到 libtnb/wire v0.3.0,各层包导出 Module 并逐类型 Export, wire.Struct 改用 Struct[T](),cleanup 签名改为 func() error - 测试断言换到 libtnb/assert 的 must/check,20 个 testify suite 拆成标准测试函数 - mock 改用 matryer 模板输出到 internal/mocks,wire 走 go.mod tool 指令, mockery 用 go run 调用,避免它的依赖树污染 go.mod - 引入 .golangci.yml 并修完全部告警,darwin 与 linux 双平台 0 issues - 工作流改用 go-version-file、govulncheck 官方 action, wire 校验移入 test.yml,mockery 由自动提交改为 PR 校验 顺带修复:pkg/db 四处漏查 rows.Err()(DatabaseExists 查询出错会被当成库不存在)、 websitestat 负值转 uint64 污染流量统计、pty 终端尺寸超 uint16 截断、 文件权限位超 32 位静默截断、面板与 ACME challenge 服务器缺 ReadHeaderTimeout、 GetDefault 丢失操作人、三处对结构体值恒成立的假断言、两处带空格而失效的 nolint Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
548 lines
17 KiB
Go
548 lines
17 KiB
Go
package pgadmin
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
stdio "io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/leonelquinteros/gotext"
|
|
"github.com/libtnb/chix/v2"
|
|
"github.com/spf13/cast"
|
|
|
|
"github.com/acepanel/panel/v3/internal/app"
|
|
"github.com/acepanel/panel/v3/internal/biz"
|
|
"github.com/acepanel/panel/v3/internal/service"
|
|
"github.com/acepanel/panel/v3/pkg/config"
|
|
"github.com/acepanel/panel/v3/pkg/firewall"
|
|
"github.com/acepanel/panel/v3/pkg/io"
|
|
"github.com/acepanel/panel/v3/pkg/shell"
|
|
"github.com/acepanel/panel/v3/pkg/systemctl"
|
|
"github.com/acepanel/panel/v3/pkg/types"
|
|
)
|
|
|
|
type App struct {
|
|
t *gotext.Locale
|
|
conf *config.Config
|
|
databaseServerRepo biz.DatabaseServerRepo
|
|
}
|
|
|
|
func NewApp(conf *config.Config, t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) *App {
|
|
return &App{
|
|
t: t,
|
|
conf: conf,
|
|
databaseServerRepo: databaseServerRepo,
|
|
}
|
|
}
|
|
|
|
// pgadminLanguage 面板语言映射为 pgAdmin 语言代码
|
|
func (s *App) pgadminLanguage() string {
|
|
switch s.conf.App.Locale {
|
|
case "":
|
|
return ""
|
|
case "zh_CN":
|
|
return "zh_Hans_CN"
|
|
case "zh_TW":
|
|
return "zh_Hant_TW"
|
|
default:
|
|
return s.conf.App.Locale
|
|
}
|
|
}
|
|
|
|
// clientIP 获取请求来源 IP,面板位于反代之后时优先取配置的 IP 头
|
|
func (s *App) clientIP(r *http.Request) string {
|
|
ip := r.RemoteAddr
|
|
if header := s.conf.HTTP.IPHeader; header != "" && r.Header.Get(header) != "" {
|
|
ip = strings.TrimSpace(strings.Split(r.Header.Get(header), ",")[0])
|
|
}
|
|
if host, _, err := net.SplitHostPort(ip); err == nil {
|
|
ip = host
|
|
}
|
|
return ip
|
|
}
|
|
|
|
func (s *App) Route(r chi.Router) {
|
|
r.Get("/info", s.Info)
|
|
r.Post("/port", s.UpdatePort)
|
|
r.Post("/login", s.Login)
|
|
r.Post("/update_username", s.UpdateUsername)
|
|
r.Post("/reset_password", s.ResetPassword)
|
|
}
|
|
|
|
func (s *App) Status() string {
|
|
ok, _ := systemctl.Status("pgadmin")
|
|
return types.AggregateAppStatus(ok)
|
|
}
|
|
|
|
func (s *App) path() string {
|
|
return app.Root + "/server/pgadmin"
|
|
}
|
|
|
|
// port 从 systemd 环境文件中解析监听端口
|
|
func (s *App) port() (uint, error) {
|
|
conf, err := io.Read(s.path() + "/pgadmin.conf")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
match := regexp.MustCompile(`PGADMIN_LISTEN=.+:(\d+)`).FindStringSubmatch(conf)
|
|
if len(match) < 2 {
|
|
return 0, errors.New(s.t.Get("pgAdmin port not found"))
|
|
}
|
|
|
|
return cast.ToUint(match[1]), nil
|
|
}
|
|
|
|
// credential 读取安装时生成的初始凭据(邮箱与密码)
|
|
func (s *App) credential() (string, string) {
|
|
raw, err := io.Read(s.path() + "/credential")
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(raw), "\n")
|
|
if len(lines) < 2 {
|
|
return strings.TrimSpace(lines[0]), ""
|
|
}
|
|
|
|
return strings.TrimSpace(lines[0]), strings.TrimSpace(lines[1])
|
|
}
|
|
|
|
func (s *App) Info(w http.ResponseWriter, r *http.Request) {
|
|
port, err := s.port()
|
|
if err != nil {
|
|
service.Error(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
email, password := s.credential()
|
|
|
|
service.Success(w, chix.M{
|
|
"port": port,
|
|
"email": email,
|
|
"password": password,
|
|
})
|
|
}
|
|
|
|
func (s *App) UpdatePort(w http.ResponseWriter, r *http.Request) {
|
|
req, err := service.Bind[UpdatePort](r)
|
|
if err != nil {
|
|
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
|
|
return
|
|
}
|
|
|
|
conf := s.path() + "/pgadmin.conf"
|
|
content, err := io.Read(conf)
|
|
if err != nil {
|
|
service.Error(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
content = regexp.MustCompile(`PGADMIN_LISTEN=(.+):\d+`).ReplaceAllString(content, "PGADMIN_LISTEN=${1}:"+cast.ToString(req.Port))
|
|
if err = io.Write(conf, content, 0600); err != nil {
|
|
service.Error(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
|
|
fw := firewall.NewFirewall()
|
|
err = fw.Port(firewall.FireInfo{
|
|
Type: firewall.TypeNormal,
|
|
PortStart: req.Port,
|
|
PortEnd: req.Port,
|
|
Strategy: firewall.StrategyAccept,
|
|
Direction: firewall.DirectionIn,
|
|
}, firewall.OperationAdd)
|
|
if err != nil {
|
|
service.Error(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
|
|
if err = systemctl.Restart("pgadmin"); err != nil {
|
|
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to restart pgAdmin: %v", err))
|
|
return
|
|
}
|
|
|
|
service.Success(w, nil)
|
|
}
|
|
|
|
// serversFile pgAdmin dump-servers/load-servers 的 JSON 结构
|
|
type serversFile struct {
|
|
Servers map[string]serverEntry `json:"Servers"`
|
|
}
|
|
|
|
type serverEntry struct {
|
|
Name string `json:"Name"`
|
|
Group string `json:"Group"`
|
|
Host string `json:"Host"`
|
|
Port int `json:"Port"`
|
|
MaintenanceDB string `json:"MaintenanceDB"`
|
|
Username string `json:"Username"`
|
|
SSLMode string `json:"SSLMode,omitempty"`
|
|
PassFile string `json:"PassFile,omitempty"`
|
|
}
|
|
|
|
// escapePgpass 转义 pgpass 字段中的反斜杠与冒号
|
|
func escapePgpass(s string) string {
|
|
return strings.NewReplacer(`\`, `\\`, `:`, `\:`).Replace(s)
|
|
}
|
|
|
|
// existingServers 只读 pgAdmin 配置库查询 Servers 组内已注册的服务器
|
|
func (s *App) existingServers(ctx context.Context, email string) (map[string]struct{}, error) {
|
|
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s/data/pgadmin.db?mode=ro", s.path()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
rows, err := db.QueryContext(ctx, `SELECT s.host, s.port, s.username FROM server s
|
|
JOIN servergroup g ON s.servergroup_id = g.id
|
|
JOIN "user" u ON s.user_id = u.id
|
|
WHERE u.email = ? AND g.name = 'Servers'`, email)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
existing := make(map[string]struct{})
|
|
for rows.Next() {
|
|
var host, username sql.NullString
|
|
var port sql.NullInt64
|
|
if err = rows.Scan(&host, &port, &username); err != nil {
|
|
return nil, err
|
|
}
|
|
existing[fmt.Sprintf("%s:%d:%s", host.String, port.Int64, username.String)] = struct{}{}
|
|
}
|
|
return existing, rows.Err()
|
|
}
|
|
|
|
// dumpExistingServers 通过 CLI 导出查询已注册服务器,直读配置库失败时的回退路径
|
|
func (s *App) dumpExistingServers(email string) map[string]struct{} {
|
|
dump := filepath.Join(os.TempDir(), "pgadmin-servers.json")
|
|
defer func() { _ = io.Remove(dump) }()
|
|
_, _ = shell.Execf("%s/cli dump-servers '%s' --user '%s'", s.path(), dump, email)
|
|
|
|
existing := make(map[string]struct{})
|
|
if raw, err := io.Read(dump); err == nil {
|
|
var dumped serversFile
|
|
if err = json.Unmarshal([]byte(raw), &dumped); err == nil {
|
|
for _, item := range dumped.Servers {
|
|
// 只认默认 Servers 组内的条目,历史版本同步进其他分组的会在此组补齐
|
|
if item.Group != "Servers" {
|
|
continue
|
|
}
|
|
existing[fmt.Sprintf("%s:%d:%s", item.Host, item.Port, item.Username)] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
return existing
|
|
}
|
|
|
|
// syncServers 将面板中全部 PostgreSQL 服务器合并注册到 pgAdmin,凭据写入 pgpass 实现免密
|
|
// 仅追加 pgAdmin 中缺失的服务器,不影响用户在 pgAdmin 中手动添加的内容
|
|
func (s *App) syncServers(ctx context.Context, email string) error {
|
|
servers, _, err := s.databaseServerRepo.List(ctx, 1, 10000, string(biz.DatabaseTypePostgresql))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(servers) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// pgAdmin server 模式下 PassFile 以用户 storage 目录为根,目录名为邮箱 @ 转 _
|
|
storageDir := fmt.Sprintf("%s/data/storage/%s", s.path(), strings.ReplaceAll(email, "@", "_"))
|
|
pgpass := filepath.Join(storageDir, "pgpass")
|
|
|
|
// 重写 pgpass 中面板服务器的凭据行,保留其他行
|
|
prefixes := make([]string, 0, len(servers))
|
|
entries := make([]string, 0, len(servers))
|
|
for _, server := range servers {
|
|
prefix := fmt.Sprintf("%s:%d:*:%s:", escapePgpass(server.Host), server.Port, escapePgpass(server.Username))
|
|
prefixes = append(prefixes, prefix)
|
|
entries = append(entries, prefix+escapePgpass(server.Password))
|
|
}
|
|
var lines []string
|
|
if raw, err := io.Read(pgpass); err == nil {
|
|
for line := range strings.SplitSeq(strings.TrimSpace(raw), "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
panelOwned := false
|
|
for _, prefix := range prefixes {
|
|
if strings.HasPrefix(line, prefix) {
|
|
panelOwned = true
|
|
break
|
|
}
|
|
}
|
|
if !panelOwned {
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
}
|
|
lines = append(lines, entries...)
|
|
if err = os.MkdirAll(storageDir, 0700); err != nil {
|
|
return err
|
|
}
|
|
if err = io.Write(pgpass, strings.Join(lines, "\n")+"\n", 0600); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 查询 pgAdmin 已有服务器用于查缺,直读配置库,异常时回退 CLI 导出
|
|
existing, err := s.existingServers(ctx, email)
|
|
if err != nil {
|
|
existing = s.dumpExistingServers(email)
|
|
}
|
|
|
|
// 一次性合并导入缺失的服务器
|
|
missing := make(map[string]serverEntry)
|
|
for i, server := range servers {
|
|
if _, ok := existing[fmt.Sprintf("%s:%d:%s", server.Host, server.Port, server.Username)]; ok {
|
|
continue
|
|
}
|
|
missing[cast.ToString(i+1)] = serverEntry{
|
|
Name: server.Name,
|
|
Group: "Servers",
|
|
Host: server.Host,
|
|
Port: int(server.Port),
|
|
MaintenanceDB: "postgres",
|
|
Username: server.Username,
|
|
SSLMode: "prefer",
|
|
PassFile: "/pgpass",
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
load := filepath.Join(os.TempDir(), "pgadmin-servers-add.json")
|
|
defer func() { _ = io.Remove(load) }()
|
|
payload, err := json.Marshal(serversFile{Servers: missing})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = io.Write(load, string(payload), 0600); err != nil {
|
|
return err
|
|
}
|
|
if out, err := shell.Execf("%s/cli load-servers '%s' --user '%s'", s.path(), load, email); err != nil {
|
|
return errors.Join(err, errors.New(out))
|
|
}
|
|
// CLI 以 root 运行,修正数据目录属主避免服务写入失败
|
|
if _, err = shell.Execf("chown -R www:www %s/data", s.path()); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 常态路径仅写入了 pgpass,精确修正属主即可
|
|
if err = io.Chown(storageDir, "www", "www"); err != nil {
|
|
return err
|
|
}
|
|
return io.Chown(pgpass, "www", "www")
|
|
}
|
|
|
|
// Login 同步面板全部 PostgreSQL 服务器后代理登录 pgAdmin 并将会话 Cookie 转发给浏览器
|
|
// 面板与 pgAdmin 同主机不同端口,Cookie 按主机共享,浏览器凭转发的 Cookie 即为已登录态
|
|
func (s *App) Login(w http.ResponseWriter, r *http.Request) {
|
|
port, err := s.port()
|
|
if err != nil {
|
|
service.Error(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
email, password := s.credential()
|
|
if email == "" || password == "" {
|
|
service.Error(w, http.StatusInternalServerError, s.t.Get("pgAdmin credential file not found"))
|
|
return
|
|
}
|
|
|
|
if err = s.syncServers(r.Context(), email); err != nil {
|
|
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to sync servers to pgAdmin: %v", err))
|
|
return
|
|
}
|
|
|
|
client := &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
|
|
// 透传浏览器 IP 与 UA,pgAdmin 增强 Cookie 保护将会话绑定到 sha256(IP|UA),
|
|
// 伪装成浏览器身份登录后浏览器直连即可通过校验,无需关闭该保护
|
|
clientIP := s.clientIP(r)
|
|
clientUA := r.UserAgent()
|
|
|
|
// 获取登录页以取得会话 Cookie 与 CSRF token
|
|
loginURL := fmt.Sprintf("http://127.0.0.1:%d/login", port)
|
|
pageReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, loginURL, nil)
|
|
if err != nil {
|
|
service.Error(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
pageReq.Header.Set("User-Agent", clientUA)
|
|
pageReq.Header.Set("X-Forwarded-For", clientIP)
|
|
pageResp, err := client.Do(pageReq)
|
|
if err != nil {
|
|
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to request pgAdmin: %v", err))
|
|
return
|
|
}
|
|
defer func() { _ = pageResp.Body.Close() }()
|
|
page, err := stdio.ReadAll(stdio.LimitReader(pageResp.Body, 4<<20))
|
|
if err != nil {
|
|
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to request pgAdmin: %v", err))
|
|
return
|
|
}
|
|
|
|
// 登录页为 React 渲染,CSRF token 在内嵌 JSON 中,保留 input 形态兼容旧版本
|
|
csrf := regexp.MustCompile(`"csrfToken":\s*"([^"]+)"`).FindStringSubmatch(string(page))
|
|
if len(csrf) < 2 {
|
|
csrf = regexp.MustCompile(`name="csrf_token"[^>]*value="([^"]+)"`).FindStringSubmatch(string(page))
|
|
}
|
|
if len(csrf) < 2 {
|
|
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to parse pgAdmin login page"))
|
|
return
|
|
}
|
|
|
|
form := url.Values{}
|
|
form.Set("csrf_token", csrf[1])
|
|
form.Set("email", email)
|
|
form.Set("password", password)
|
|
// 语言跟随面板设置,pgAdmin 会将 language 字段固化进会话
|
|
if lang := s.pgadminLanguage(); lang != "" {
|
|
form.Set("language", lang)
|
|
}
|
|
|
|
loginReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/authenticate/login", port), strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
service.Error(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
loginReq.Header.Set("User-Agent", clientUA)
|
|
loginReq.Header.Set("X-Forwarded-For", clientIP)
|
|
for _, cookie := range pageResp.Cookies() {
|
|
loginReq.AddCookie(cookie)
|
|
}
|
|
|
|
loginResp, err := client.Do(loginReq)
|
|
if err != nil {
|
|
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to request pgAdmin: %v", err))
|
|
return
|
|
}
|
|
defer func() { _ = loginResp.Body.Close() }()
|
|
_, _ = stdio.Copy(stdio.Discard, stdio.LimitReader(loginResp.Body, 4<<20))
|
|
|
|
// 登录成功时 pgAdmin 返回 302 且跳转目标不是登录页
|
|
location := loginResp.Header.Get("Location")
|
|
if loginResp.StatusCode != http.StatusFound || strings.Contains(location, "/login") {
|
|
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to login pgAdmin, please check the credential of pgAdmin"))
|
|
return
|
|
}
|
|
|
|
// 改写 SameSite 以兼容面板 https 跳转 http 的场景
|
|
for _, cookie := range append(pageResp.Cookies(), loginResp.Cookies()...) {
|
|
cookie.SameSite = http.SameSiteLaxMode
|
|
cookie.Secure = false
|
|
http.SetCookie(w, cookie)
|
|
}
|
|
|
|
service.Success(w, chix.M{
|
|
"port": port,
|
|
})
|
|
}
|
|
|
|
// ResetPassword 通过 CLI 重置管理员密码并同步凭据文件
|
|
// UpdateUsername 修改管理员账号,迁移服务器配置与用户存储目录
|
|
func (s *App) UpdateUsername(w http.ResponseWriter, r *http.Request) {
|
|
req, err := service.Bind[UpdateUsername](r)
|
|
if err != nil {
|
|
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
|
|
return
|
|
}
|
|
|
|
oldEmail, password := s.credential()
|
|
if oldEmail == "" || password == "" {
|
|
service.Error(w, http.StatusInternalServerError, s.t.Get("pgAdmin credential file not found"))
|
|
return
|
|
}
|
|
if req.Username == oldEmail {
|
|
service.Success(w, nil)
|
|
return
|
|
}
|
|
|
|
// 以当前密码创建新管理员账号
|
|
if out, err := shell.Execf("%s/cli add-user '%s' '%s' --admin", s.path(), req.Username, password); err != nil {
|
|
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to create new account: %v", errors.Join(err, errors.New(out))))
|
|
return
|
|
}
|
|
|
|
// 迁移服务器连接配置到新账号
|
|
dump := filepath.Join(os.TempDir(), "pgadmin-servers-migrate.json")
|
|
defer func() { _ = io.Remove(dump) }()
|
|
_, _ = shell.Execf("%s/cli dump-servers '%s' --user '%s'", s.path(), dump, oldEmail)
|
|
if io.Exists(dump) {
|
|
_, _ = shell.Execf("%s/cli load-servers '%s' --user '%s'", s.path(), dump, req.Username)
|
|
}
|
|
|
|
// 删除旧账号
|
|
if out, err := shell.Execf("%s/cli delete-user '%s' --yes", s.path(), oldEmail); err != nil {
|
|
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to delete old account: %v", errors.Join(err, errors.New(out))))
|
|
return
|
|
}
|
|
|
|
// 迁移用户存储目录(pgpass 等),目录名为邮箱 @ 转 _
|
|
oldDir := fmt.Sprintf("%s/data/storage/%s", s.path(), strings.ReplaceAll(oldEmail, "@", "_"))
|
|
newDir := fmt.Sprintf("%s/data/storage/%s", s.path(), strings.ReplaceAll(req.Username, "@", "_"))
|
|
if io.Exists(oldDir) && !io.Exists(newDir) {
|
|
_ = os.Rename(oldDir, newDir)
|
|
}
|
|
|
|
// CLI 以 root 运行,修正数据目录属主避免服务写入失败
|
|
if _, err = shell.Execf("chown -R www:www %s/data", s.path()); err != nil {
|
|
service.Error(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
|
|
// 更新凭据文件,下次登录按新账号重新同步
|
|
if err = io.Write(s.path()+"/credential", req.Username+"\n"+password+"\n", 0600); err != nil {
|
|
service.Error(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
|
|
service.Success(w, nil)
|
|
}
|
|
|
|
func (s *App) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
|
req, err := service.Bind[ResetPassword](r)
|
|
if err != nil {
|
|
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
|
|
return
|
|
}
|
|
|
|
email, _ := s.credential()
|
|
if email == "" {
|
|
service.Error(w, http.StatusInternalServerError, s.t.Get("pgAdmin credential file not found"))
|
|
return
|
|
}
|
|
|
|
// cli 为安装脚本生成的稳定入口,屏蔽上游命令名随大版本变化
|
|
if out, err := shell.Execf("%s/cli update-user '%s' --password '%s'", s.path(), email, req.Password); err != nil {
|
|
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to reset password: %v", errors.Join(err, errors.New(out))))
|
|
return
|
|
}
|
|
// CLI 以 root 运行,修正数据目录属主避免服务写入失败
|
|
if _, err = shell.Execf("chown -R www:www %s/data", s.path()); err != nil {
|
|
service.Error(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
|
|
if err = io.Write(s.path()+"/credential", email+"\n"+req.Password+"\n", 0600); err != nil {
|
|
service.Error(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
|
|
service.Success(w, nil)
|
|
}
|