feat(web-auth): 支持六位密码与环境变量托管

- 密码策略统一为至少六个可见字符,覆盖初始化与设置中心改密
- 支持 GONAVI_WEB_PASSWORD 启动同步并保留既有 2FA 与会话配置
- 设置中心识别环境托管状态并禁用临时改密
- Compose、env 示例及双语文档补充密码配置和容器重建说明
- 增加认证持久化、环境覆盖、前端状态与 Docker 登录回归校验
This commit is contained in:
Syngnat
2026-07-10 10:34:03 +08:00
parent 0a9aec5929
commit 8d186fc066
21 changed files with 417 additions and 62 deletions

View File

@@ -50,7 +50,14 @@ jobs:
- name: Validate runtime Dockerfiles
if: matrix.image_name == 'gonavi-web-server'
shell: bash
run: bash tools/validate-web-server-dockerfile.test.sh
run: |
set -euo pipefail
bash tools/validate-web-server-dockerfile.test.sh
env_file="$(mktemp)"
trap 'rm -f "$env_file"' EXIT
printf 'GONAVI_HOST_DATA_ROOT=%s\nGONAVI_WEB_PASSWORD=123456\n' "${RUNNER_TEMP}/gonavi-data" >"$env_file"
rendered="$(docker compose --env-file "$env_file" -f docker-compose.web-server.yml config)"
grep -Eq "GONAVI_WEB_PASSWORD: ['\"]?123456['\"]?$" <<<"$rendered"
- name: Prepare build variables
id: prep
@@ -111,17 +118,29 @@ jobs:
shell: bash
run: |
set -euo pipefail
cid="$(docker run -d -p 34116:34116 -e GONAVI_WEB_ADDR=0.0.0.0:34116 codex-smoke/${{ matrix.image_name }}:local web-server)"
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
cookie_jar="$(mktemp)"
cid=""
trap 'rm -f "$cookie_jar"; if [[ -n "$cid" ]]; then docker rm -f "$cid" >/dev/null 2>&1 || true; fi' EXIT
cid="$(docker run -d -p 34116:34116 -e GONAVI_WEB_ADDR=0.0.0.0:34116 -e GONAVI_WEB_PASSWORD=123456 codex-smoke/${{ matrix.image_name }}:local web-server)"
ready=false
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
if curl --fail --silent http://127.0.0.1:34116/__gonavi/healthz >/dev/null; then
exit 0
ready=true
break
fi
sleep 1
done
docker logs "$cid"
echo "Web Server image smoke test failed" >&2
exit 1
if [[ "$ready" != "true" ]]; then
docker logs "$cid"
echo "Web Server image smoke test failed" >&2
exit 1
fi
status="$(curl --fail --silent --show-error http://127.0.0.1:34116/__gonavi/auth/status)"
grep -Fq '"configured":true' <<<"$status"
login="$(curl --fail --silent --show-error -c "$cookie_jar" -H 'Content-Type: application/json' -d '{"password":"123456","code":""}' http://127.0.0.1:34116/__gonavi/auth/login)"
grep -Fq '"success":true' <<<"$login"
app_status="$(curl --silent --show-error -o /dev/null -w '%{http_code}' -b "$cookie_jar" http://127.0.0.1:34116/)"
[[ "$app_status" == "200" ]]
- name: Smoke test build environment image
if: matrix.image_name == 'gonavi-build-env'

2
.gitignore vendored
View File

@@ -15,6 +15,8 @@ frontend/wailsjs/tsconfig.json
dist/
.DS_Store
.gemini-clipboard
.env
docker.*.env
GoNavi-Wails
GoNavi-Wails.exe
optional-driver-agent

View File

@@ -57,6 +57,6 @@ EXPOSE 34116
USER gonavi
# 首次访问 /setup 初始化 Web 管理员密码;认证状态落在数据目录 web_auth.json
# 未通过环境变量配置密码时,首次访问 /setup 初始化;认证状态落在数据目录 web_auth.json
ENTRYPOINT ["/usr/local/bin/gonavi"]
CMD ["web-server"]

View File

@@ -195,7 +195,7 @@ go build .
.\GoNavi-Wails.exe web-server --addr 127.0.0.1:34116
```
The first browser visit is redirected to `/setup` to create the web admin password. Google Authenticator is optional but supported out of the box, together with `web_auth.json`, session cookies, recovery codes, and login rate limiting.
Without a configured password, the first browser visit is redirected to `/setup` to create the web admin password. Google Authenticator is optional but supported out of the box, together with `web_auth.json`, session cookies, recovery codes, and login rate limiting.
Current scope already includes:
@@ -216,13 +216,28 @@ Still in progress:
```bash
cp docker.web-server.env.example docker.web-server.env
# set GONAVI_HOST_DATA_ROOT to an absolute path
# optionally set GONAVI_WEB_PASSWORD to at least 6 characters
docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d
```
Open `http://127.0.0.1:34116` (or the mapped host port). Complete `/setup` on first visit.
You can alternatively put the same variables in the project-level `.env` file and omit `--env-file docker.web-server.env`.
Open `http://127.0.0.1:34116` (or the mapped host port). When `GONAVI_WEB_PASSWORD` is set, a newly created container synchronizes it and opens the login page directly. Otherwise, complete `/setup` on first visit.
Mount the GoNavi active data root at `/data`. Prefer including `connections.json`, `daily_secrets.json`, and optional `drivers/`. Web auth state is stored as `web_auth.json` in that directory.
`GONAVI_WEB_PASSWORD` takes precedence over the password hash already stored in `web_auth.json`, while preserving 2FA and session settings. Removing the variable leaves the last synchronized password active. Treat the env file as a secret because the plaintext value is visible to users who can inspect the container; restrict its file permissions.
After changing the env password, recreate the container so Compose reloads the env file:
```bash
docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d --force-recreate
```
Docker Desktop Restart and `docker restart` keep the old container environment and do not reload the env file.
On a new deployment, an environment password starts password-only authentication. To enable 2FA, leave `GONAVI_WEB_PASSWORD` empty for the initial `/setup`, then set it to the same password before recreating the container.
The container listens on `0.0.0.0:34116` by default (`GONAVI_WEB_ADDR`). **Do not expose an unhardened Web entrypoint to the public internet**; use a reverse proxy and HTTPS for production.
Default Compose pulls the GHCR image. For a local source build, add the override:

View File

@@ -189,7 +189,7 @@ go build .
.\GoNavi-Wails.exe web-server --addr 127.0.0.1:34116
```
首次访问会进入 `/setup` 完成 Web 管理员密码初始化;可选启用 Google Authenticator服务端会落地 `web_auth.json`、Session Cookie、恢复码与登录失败限流。
未配置密码时,首次访问会进入 `/setup` 完成 Web 管理员密码初始化;可选启用 Google Authenticator服务端会落地 `web_auth.json`、Session Cookie、恢复码与登录失败限流。
当前阶段已具备:
@@ -210,13 +210,28 @@ go build .
```bash
cp docker.web-server.env.example docker.web-server.env
# 编辑 GONAVI_HOST_DATA_ROOT 为绝对路径
# 可选设置至少 6 位的 GONAVI_WEB_PASSWORD
docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d
```
浏览器打开 `http://127.0.0.1:34116`(或宿主机映射端口)。首次访问完成 `/setup`
也可以把相同变量写入项目目录下的标准 `.env` 文件,此时可省略 `--env-file docker.web-server.env`
浏览器打开 `http://127.0.0.1:34116`(或宿主机映射端口)。设置 `GONAVI_WEB_PASSWORD` 后,新创建的容器会同步该密码并直接进入登录页;未设置时首次访问完成 `/setup`
请把 GoNavi 活动数据目录挂载为 `/data`。建议至少包含 `connections.json``daily_secrets.json`;如需可选驱动代理应包含 `drivers/`。Web 认证状态写入同目录下的 `web_auth.json`
`GONAVI_WEB_PASSWORD` 会覆盖 `web_auth.json` 中已有的密码哈希,但保留 2FA 和会话策略移除环境变量后继续使用最后一次同步的密码。env 文件包含明文密码,可被有容器检查权限的用户看到,请限制文件读取权限。
修改 env 密码后,需要重新创建容器,让 Compose 重新读取 env 文件:
```bash
docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d --force-recreate
```
Docker Desktop 的 Restart 和 `docker restart` 会沿用旧容器环境,不会重新读取 env 文件。
新部署直接设置环境密码时会启用纯密码认证。如需启用 2FA请先留空 `GONAVI_WEB_PASSWORD` 并通过 `/setup` 初始化,再将它设置为同一密码后重新创建容器。
容器内默认监听 `0.0.0.0:34116``GONAVI_WEB_ADDR`)。**不要把未加固的 Web 入口直接暴露到公网**;生产环境请配合反向代理与 HTTPS。
默认 Compose 拉取 GHCR 预构建镜像。本地源码构建叠加 override

View File

@@ -10,6 +10,7 @@ services:
GONAVI_DATA_ROOT: /data
GONAVI_LOG_DIR: /var/lib/gonavi/logs
GONAVI_WEB_ADDR: 0.0.0.0:34116
GONAVI_WEB_PASSWORD: ${GONAVI_WEB_PASSWORD:-}
volumes:
- type: bind
source: ${GONAVI_HOST_DATA_ROOT}

View File

@@ -1,7 +1,7 @@
# 宿主机 GoNavi 活动数据目录。
# 建议至少包含connections.json、daily_secrets.json
# 如需可选驱动代理,一并挂载 drivers/。
# Web 认证状态会写入 web_auth.json首次访问 /setup 初始化
# Web 认证状态会写入 web_auth.json;未设置环境密码时首次访问 /setup 初始化。
GONAVI_HOST_DATA_ROOT=/absolute/path/to/gonavi-data
# 预构建镜像地址。稳定版通常使用 latest跟随 dev 分支可改成 ghcr.io/syngnat/gonavi-web-server:dev-latest
@@ -12,3 +12,8 @@ GONAVI_WEB_PULL_POLICY=missing
# 宿主机暴露端口(映射到容器 34116
GONAVI_WEB_HTTP_PORT=34116
# Web 管理员密码(至少 6 位)。留空时首次访问 /setup 初始化;公网部署请使用更强密码。
# 设置或修改后请执行 docker compose up -d --force-recreate请限制本文件的读取权限。
# 新部署如需启用 2FA请先留空并通过 /setup 初始化,再设置为同一密码并重新创建容器。
GONAVI_WEB_PASSWORD=

View File

@@ -0,0 +1,25 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const source = readFileSync(new URL('./WebAuthSettingsPanel.tsx', import.meta.url), 'utf8');
const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'];
describe('WebAuthSettingsPanel environment-managed password', () => {
it('disables password changes and explains how to update the password', () => {
expect(source).toContain('passwordManagedByEnvironment: boolean;');
expect(source).toContain('summary?.passwordManagedByEnvironment === true');
expect(source).toContain("t('app.settings.web_auth.password.managed_by_environment')");
expect(source).toContain('disabled={passwordManagedByEnvironment}');
});
it('keeps the six-character and environment-managed copy in every locale', () => {
for (const locale of locales) {
const catalog = JSON.parse(
readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8'),
) as Record<string, string>;
expect(catalog['app.settings.web_auth.password.new_placeholder']).toContain('6');
expect(catalog['app.settings.web_auth.password.managed_by_environment']).toContain('GONAVI_WEB_PASSWORD');
expect(catalog['web_auth.error.password_managed_by_environment']).toBeTruthy();
}
});
});

View File

@@ -10,6 +10,7 @@ type WebAuthSettingsSummary = {
sessionAbsoluteHours: number;
sessionRememberDays: number;
updatedAt?: string;
passwordManagedByEnvironment: boolean;
};
type WebAuthSettingsPanelProps = {
@@ -80,6 +81,7 @@ const WebAuthSettingsPanel: React.FC<WebAuthSettingsPanelProps> = ({
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [passwordForm, setPasswordForm] = useState<PasswordFormState>(() => createEmptyPasswordFormState());
const passwordManagedByEnvironment = summary?.passwordManagedByEnvironment === true;
const summaryItems = useMemo(() => {
if (!summary) {
@@ -245,9 +247,11 @@ const WebAuthSettingsPanel: React.FC<WebAuthSettingsPanelProps> = ({
<div style={{ display: 'grid', gap: 4 }}>
<div style={sectionTitleStyle(titleColor)}>{t('app.settings.web_auth.password.title')}</div>
<div style={{ color: mutedColor, fontSize: 13, lineHeight: 1.6 }}>
{summary?.totpEnabled
? t('app.settings.web_auth.password.description_with_code')
: t('app.settings.web_auth.password.description')}
{passwordManagedByEnvironment
? t('app.settings.web_auth.password.managed_by_environment')
: summary?.totpEnabled
? t('app.settings.web_auth.password.description_with_code')
: t('app.settings.web_auth.password.description')}
</div>
</div>
<div
@@ -261,6 +265,7 @@ const WebAuthSettingsPanel: React.FC<WebAuthSettingsPanelProps> = ({
<span style={{ color: mutedColor, fontSize: 12 }}>{t('app.settings.web_auth.password.current_label')}</span>
<Input.Password
autoComplete="current-password"
disabled={passwordManagedByEnvironment}
value={passwordForm.currentPassword}
placeholder={t('app.settings.web_auth.password.current_placeholder')}
onChange={(event) => updatePasswordField('currentPassword', event.target.value)}
@@ -271,6 +276,7 @@ const WebAuthSettingsPanel: React.FC<WebAuthSettingsPanelProps> = ({
<span style={{ color: mutedColor, fontSize: 12 }}>{t('app.settings.web_auth.password.code_label')}</span>
<Input
autoComplete="one-time-code"
disabled={passwordManagedByEnvironment}
inputMode="numeric"
value={passwordForm.code}
placeholder={t('app.settings.web_auth.password.code_placeholder')}
@@ -282,6 +288,7 @@ const WebAuthSettingsPanel: React.FC<WebAuthSettingsPanelProps> = ({
<span style={{ color: mutedColor, fontSize: 12 }}>{t('app.settings.web_auth.password.new_label')}</span>
<Input.Password
autoComplete="new-password"
disabled={passwordManagedByEnvironment}
value={passwordForm.newPassword}
placeholder={t('app.settings.web_auth.password.new_placeholder')}
onChange={(event) => updatePasswordField('newPassword', event.target.value)}
@@ -291,6 +298,7 @@ const WebAuthSettingsPanel: React.FC<WebAuthSettingsPanelProps> = ({
<span style={{ color: mutedColor, fontSize: 12 }}>{t('app.settings.web_auth.password.confirm_label')}</span>
<Input.Password
autoComplete="new-password"
disabled={passwordManagedByEnvironment}
value={passwordForm.confirmPassword}
placeholder={t('app.settings.web_auth.password.confirm_placeholder')}
onChange={(event) => updatePasswordField('confirmPassword', event.target.value)}
@@ -298,7 +306,12 @@ const WebAuthSettingsPanel: React.FC<WebAuthSettingsPanelProps> = ({
</label>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
<Button type="primary" loading={submitting} onClick={() => { void handleSubmit(); }}>
<Button
type="primary"
loading={submitting}
disabled={passwordManagedByEnvironment}
onClick={() => { void handleSubmit(); }}
>
{t('app.settings.web_auth.password.submit')}
</Button>
</div>

View File

@@ -22,6 +22,7 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
"GoNavi-Wails/internal/appdata"
"github.com/skip2/go-qrcode"
@@ -39,7 +40,8 @@ const (
webDefaultSessionIdleMinutes = 30
webDefaultSessionAbsoluteHours = 24 * 7
webDefaultSessionRememberDays = 7
webMinPasswordLength = 10
webMinPasswordLength = 6
webAuthPasswordEnvName = "GONAVI_WEB_PASSWORD"
webTOTPPeriodSeconds = 30
webTOTPDigits = 6
)
@@ -52,6 +54,7 @@ var (
errWebAuthInvalidSetup = errors.New("invalid setup token")
errWebAuthInvalidCredentials = errors.New("invalid credentials")
errWebAuthRateLimited = errors.New("too many login attempts")
errWebAuthPasswordManaged = errors.New("web auth password is managed by environment")
)
type webAuthConfig struct {
@@ -337,13 +340,14 @@ func (m *webSessionManager) DestroyAll() {
}
type webAuthManager struct {
mu sync.RWMutex
store *webAuthStore
config webAuthConfig
pending map[string]pendingSetup
sessions *webSessionManager
loginAttempts *loginAttemptTracker
now func() time.Time
mu sync.RWMutex
store *webAuthStore
config webAuthConfig
pending map[string]pendingSetup
sessions *webSessionManager
loginAttempts *loginAttemptTracker
now func() time.Time
passwordManagedByEnvironment bool
}
type webAuthStatus struct {
@@ -356,29 +360,75 @@ type webAuthStatus struct {
}
type webAuthSettingsSummary struct {
Configured bool `json:"configured"`
TOTPEnabled bool `json:"totpEnabled"`
RecoveryCodesRemaining int `json:"recoveryCodesRemaining"`
SessionIdleMinutes int `json:"sessionIdleMinutes,omitempty"`
SessionAbsoluteHours int `json:"sessionAbsoluteHours,omitempty"`
SessionRememberDays int `json:"sessionRememberDays,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
Configured bool `json:"configured"`
TOTPEnabled bool `json:"totpEnabled"`
RecoveryCodesRemaining int `json:"recoveryCodesRemaining"`
SessionIdleMinutes int `json:"sessionIdleMinutes,omitempty"`
SessionAbsoluteHours int `json:"sessionAbsoluteHours,omitempty"`
SessionRememberDays int `json:"sessionRememberDays,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
PasswordManagedByEnvironment bool `json:"passwordManagedByEnvironment"`
}
func newWebAuthManager(root string) (*webAuthManager, error) {
return newWebAuthManagerWithPassword(root, "")
}
func newWebAuthManagerFromEnvironment(root string) (*webAuthManager, error) {
return newWebAuthManagerWithPassword(root, os.Getenv(webAuthPasswordEnvName))
}
func newWebAuthManagerWithPassword(root string, configuredPassword string) (*webAuthManager, error) {
store := newWebAuthStore(root)
cfg, err := store.Load()
if err != nil {
return nil, err
}
return &webAuthManager{
manager := &webAuthManager{
store: store,
config: cfg,
pending: make(map[string]pendingSetup),
sessions: newWebSessionManager(),
loginAttempts: newLoginAttemptTracker(),
now: time.Now,
}, nil
}
if err := manager.applyEnvironmentPassword(configuredPassword); err != nil {
return nil, err
}
return manager, nil
}
func (m *webAuthManager) applyEnvironmentPassword(password string) error {
if strings.TrimSpace(password) == "" {
return nil
}
normalizedPassword, err := normalizeWebAuthPassword(password)
if err != nil {
return fmt.Errorf("%s: %w", webAuthPasswordEnvName, err)
}
m.passwordManagedByEnvironment = true
m.mu.Lock()
defer m.mu.Unlock()
cfg := cloneWebAuthConfig(m.config)
if cfg.IsConfigured() && verifyPassword(cfg.PasswordHash, normalizedPassword) {
return nil
}
passwordHash, err := hashPassword(normalizedPassword)
if err != nil {
return fmt.Errorf("%s: %w", webAuthPasswordEnvName, err)
}
if !cfg.IsConfigured() {
cfg = normalizeWebAuthConfig(webAuthConfig{Enabled: true})
}
cfg.Enabled = true
cfg.PasswordHash = passwordHash
cfg.UpdatedAt = m.now().UTC().Format(time.RFC3339)
if err := m.store.Save(cfg); err != nil {
return fmt.Errorf("persist %s: %w", webAuthPasswordEnvName, err)
}
m.config = cfg
return nil
}
func (m *webAuthManager) currentConfig() webAuthConfig {
@@ -395,15 +445,16 @@ func cloneWebAuthConfig(cfg webAuthConfig) webAuthConfig {
return copied
}
func buildWebAuthSettingsSummary(cfg webAuthConfig) webAuthSettingsSummary {
func buildWebAuthSettingsSummary(cfg webAuthConfig, passwordManagedByEnvironment bool) webAuthSettingsSummary {
return webAuthSettingsSummary{
Configured: cfg.IsConfigured(),
TOTPEnabled: cfg.TOTPEnabled,
RecoveryCodesRemaining: len(cfg.RecoveryCodeHashes),
SessionIdleMinutes: cfg.SessionIdleMinutes,
SessionAbsoluteHours: cfg.SessionAbsoluteHours,
SessionRememberDays: cfg.SessionRememberDays,
UpdatedAt: strings.TrimSpace(cfg.UpdatedAt),
Configured: cfg.IsConfigured(),
TOTPEnabled: cfg.TOTPEnabled,
RecoveryCodesRemaining: len(cfg.RecoveryCodeHashes),
SessionIdleMinutes: cfg.SessionIdleMinutes,
SessionAbsoluteHours: cfg.SessionAbsoluteHours,
SessionRememberDays: cfg.SessionRememberDays,
UpdatedAt: strings.TrimSpace(cfg.UpdatedAt),
PasswordManagedByEnvironment: passwordManagedByEnvironment,
}
}
@@ -427,7 +478,7 @@ func (m *webAuthManager) Settings() (webAuthSettingsSummary, error) {
if !cfg.IsConfigured() {
return webAuthSettingsSummary{}, errWebAuthNotConfigured
}
return buildWebAuthSettingsSummary(cfg), nil
return buildWebAuthSettingsSummary(cfg, m.passwordManagedByEnvironment), nil
}
func (m *webAuthManager) BeginSetup(host string) (pendingSetupResponse, error) {
@@ -497,9 +548,9 @@ func (m *webAuthManager) CompleteSetup(token string, password string, code strin
delete(m.pending, strings.TrimSpace(token))
return webAuthConfig{}, "", errWebAuthSetupExpired
}
normalizedPassword := strings.TrimSpace(password)
if len(normalizedPassword) < webMinPasswordLength {
return webAuthConfig{}, "", fmt.Errorf("password must be at least %d characters", webMinPasswordLength)
normalizedPassword, err := normalizeWebAuthPassword(password)
if err != nil {
return webAuthConfig{}, "", err
}
if enableTOTP && !validateTOTPCode(setup.Secret, code, m.now()) {
return webAuthConfig{}, "", fmt.Errorf("invalid google authenticator code")
@@ -588,6 +639,9 @@ func (m *webAuthManager) Logout(sessionID string) {
}
func (m *webAuthManager) ChangePassword(currentPassword string, code string, nextPassword string) (webAuthConfig, string, bool, error) {
if m.passwordManagedByEnvironment {
return webAuthConfig{}, "", false, errWebAuthPasswordManaged
}
now := m.now()
m.mu.Lock()
@@ -792,11 +846,22 @@ func generateTOTPCodeAt(secret string, now time.Time) (string, error) {
return fmt.Sprintf("%06d", code), nil
}
func hashPassword(password string) (string, error) {
func normalizeWebAuthPassword(password string) (string, error) {
normalized := strings.TrimSpace(password)
if normalized == "" {
return "", fmt.Errorf("password is required")
}
if utf8.RuneCountInString(normalized) < webMinPasswordLength {
return "", fmt.Errorf("password must be at least %d characters", webMinPasswordLength)
}
return normalized, nil
}
func hashPassword(password string) (string, error) {
normalized, err := normalizeWebAuthPassword(password)
if err != nil {
return "", err
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err

View File

@@ -208,6 +208,8 @@ func (s *Server) handleAuthPasswordChange(w http.ResponseWriter, r *http.Request
status = http.StatusPreconditionFailed
case errors.Is(err, errWebAuthInvalidCredentials):
status = http.StatusUnauthorized
case errors.Is(err, errWebAuthPasswordManaged):
status = http.StatusConflict
}
s.writeAuthJSONError(w, status, localizeWebAuthError(localizer, err), 0)
return
@@ -217,7 +219,7 @@ func (s *Server) handleAuthPasswordChange(w http.ResponseWriter, r *http.Request
s.writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"usedRecoveryCode": usedRecoveryCode,
"settings": buildWebAuthSettingsSummary(cfg),
"settings": buildWebAuthSettingsSummary(cfg, s.auth.passwordManagedByEnvironment),
})
}
@@ -363,6 +365,8 @@ func localizeWebAuthError(localizer *i18n.Localizer, err error) string {
return webAuthText(localizer, "web_auth.error.invalid_password_or_code", nil)
case errors.Is(err, errWebAuthRateLimited):
return webAuthText(localizer, "web_auth.error.too_many_login_attempts", nil)
case errors.Is(err, errWebAuthPasswordManaged):
return webAuthText(localizer, "web_auth.error.password_managed_by_environment", nil)
}
message := strings.TrimSpace(err.Error())
@@ -1241,7 +1245,7 @@ function validateStep(stepIndex) {
showError(i18n.passwordRequired);
return false;
}
if (password.length < ` + fmt.Sprintf("%d", webMinPasswordLength) + `) {
if (Array.from(password).length < ` + fmt.Sprintf("%d", webMinPasswordLength) + `) {
showError(i18n.passwordTooShort);
return false;
}

View File

@@ -59,6 +59,7 @@ func TestRenderAuthPageUsesLocalizedCopyAndSolidColors(t *testing.T) {
"初始化 GoNavi Web",
"发行方",
"账户",
"至少 6 位",
} {
if !strings.Contains(page, fragment) {
t.Fatalf("expected rendered page to contain %q", fragment)
@@ -85,4 +86,10 @@ func TestRenderAuthPageUsesLocalizedCopyAndSolidColors(t *testing.T) {
t.Fatalf("expected setup wizard page to contain %q", fragment)
}
}
if strings.Contains(page, "至少 10 位") {
t.Fatal("expected setup page to stop advertising the old ten-character minimum")
}
if !strings.Contains(page, "Array.from(password).length < 6") {
t.Fatal("expected setup page to count visible password characters consistently with the backend")
}
}

View File

@@ -2,6 +2,7 @@ package webserver
import (
"errors"
"os"
"strings"
"testing"
"time"
@@ -94,6 +95,167 @@ func TestGenerateQRCodeDataURL(t *testing.T) {
}
}
func TestWebAuthManagerEnforcesSixCharacterPasswords(t *testing.T) {
if _, err := normalizeWebAuthPassword("密码"); err == nil {
t.Fatal("expected two visible characters to remain below the six-character minimum")
}
if _, err := normalizeWebAuthPassword("密码设置安全"); err != nil {
t.Fatalf("expected six Unicode characters to satisfy the password minimum, got %v", err)
}
manager, err := newWebAuthManager(t.TempDir())
if err != nil {
t.Fatalf("newWebAuthManager failed: %v", err)
}
setup, err := manager.BeginSetup("127.0.0.1:34116")
if err != nil {
t.Fatalf("BeginSetup failed: %v", err)
}
if _, _, err := manager.CompleteSetup(setup.SetupToken, "12345", "", false, 30, 24, 7); err == nil || !strings.Contains(err.Error(), "at least 6") {
t.Fatalf("expected five-character setup password to be rejected, got %v", err)
}
cfg, _, err := manager.CompleteSetup(setup.SetupToken, "123456", "", false, 30, 24, 7)
if err != nil {
t.Fatalf("expected six-character setup password to succeed, got %v", err)
}
if !verifyPassword(cfg.PasswordHash, "123456") {
t.Fatal("expected six-character setup password to be persisted")
}
if _, _, _, err := manager.ChangePassword("123456", "", "65432"); err == nil || !strings.Contains(err.Error(), "at least 6") {
t.Fatalf("expected five-character replacement password to be rejected, got %v", err)
}
if !verifyPassword(manager.currentConfig().PasswordHash, "123456") {
t.Fatal("expected rejected password change to preserve the current password")
}
nextCfg, _, _, err := manager.ChangePassword("123456", "", "654321")
if err != nil {
t.Fatalf("expected six-character replacement password to succeed, got %v", err)
}
if !verifyPassword(nextCfg.PasswordHash, "654321") {
t.Fatal("expected six-character replacement password to be persisted")
}
}
func TestNewWebAuthManagerFromEnvironmentInitializesPassword(t *testing.T) {
root := t.TempDir()
t.Setenv(webAuthPasswordEnvName, "123456")
manager, err := newWebAuthManagerFromEnvironment(root)
if err != nil {
t.Fatalf("newWebAuthManagerFromEnvironment failed: %v", err)
}
if status := manager.Status(""); !status.Configured {
t.Fatalf("expected environment password to configure web auth, got %+v", status)
}
settings, err := manager.Settings()
if err != nil {
t.Fatalf("read environment-managed settings failed: %v", err)
}
if !settings.PasswordManagedByEnvironment {
t.Fatal("expected settings to report that the password is managed by environment")
}
if _, _, _, _, err := manager.Login("123456", "", "127.0.0.1"); err != nil {
t.Fatalf("expected login with environment password to succeed, got %v", err)
}
if _, _, _, err := manager.ChangePassword("123456", "", "654321"); !errors.Is(err, errWebAuthPasswordManaged) {
t.Fatalf("expected environment-managed password change to be rejected, got %v", err)
}
payload, err := os.ReadFile(manager.store.path)
if err != nil {
t.Fatalf("read persisted web auth config failed: %v", err)
}
if strings.Contains(string(payload), "123456") {
t.Fatal("environment password must not be persisted in plaintext")
}
t.Setenv(webAuthPasswordEnvName, "")
restarted, err := newWebAuthManagerFromEnvironment(root)
if err != nil {
t.Fatalf("restart after clearing environment password failed: %v", err)
}
settings, err = restarted.Settings()
if err != nil {
t.Fatalf("read settings after clearing environment password failed: %v", err)
}
if settings.PasswordManagedByEnvironment {
t.Fatal("expected password management to return to the settings UI after clearing the environment variable")
}
if _, _, _, _, err := restarted.Login("123456", "", "127.0.0.1"); err != nil {
t.Fatalf("expected the last synchronized password to remain active, got %v", err)
}
}
func TestNewWebAuthManagerFromEnvironmentOverridesPasswordAndPreservesSettings(t *testing.T) {
root := t.TempDir()
manager, err := newWebAuthManager(root)
if err != nil {
t.Fatalf("newWebAuthManager failed: %v", err)
}
now := time.Unix(1_720_000_321, 0).UTC()
manager.now = func() time.Time { return now }
setup, err := manager.BeginSetup("127.0.0.1:34116")
if err != nil {
t.Fatalf("BeginSetup failed: %v", err)
}
code, err := generateTOTPCodeAt(setup.Secret, now)
if err != nil {
t.Fatalf("generateTOTPCodeAt failed: %v", err)
}
original, _, err := manager.CompleteSetup(setup.SetupToken, "old-password", code, true, 45, 48, 14)
if err != nil {
t.Fatalf("CompleteSetup failed: %v", err)
}
t.Setenv(webAuthPasswordEnvName, "654321")
restarted, err := newWebAuthManagerFromEnvironment(root)
if err != nil {
t.Fatalf("restart with environment password failed: %v", err)
}
updated := restarted.currentConfig()
if verifyPassword(updated.PasswordHash, "old-password") || !verifyPassword(updated.PasswordHash, "654321") {
t.Fatal("expected environment password to replace the persisted password")
}
if !updated.TOTPEnabled || updated.TOTPSecret != original.TOTPSecret || len(updated.RecoveryCodeHashes) != len(original.RecoveryCodeHashes) {
t.Fatal("expected environment password update to preserve TOTP settings")
}
if updated.SessionIdleMinutes != 45 || updated.SessionAbsoluteHours != 48 || updated.SessionRememberDays != 14 {
t.Fatalf("expected environment password update to preserve session settings, got %+v", updated)
}
settings, err := restarted.Settings()
if err != nil || !settings.PasswordManagedByEnvironment {
t.Fatalf("expected restarted settings to report environment management, settings=%+v err=%v", settings, err)
}
before, err := os.ReadFile(restarted.store.path)
if err != nil {
t.Fatalf("read updated web auth config failed: %v", err)
}
if _, err := newWebAuthManagerFromEnvironment(root); err != nil {
t.Fatalf("restart with unchanged environment password failed: %v", err)
}
after, err := os.ReadFile(restarted.store.path)
if err != nil {
t.Fatalf("read unchanged web auth config failed: %v", err)
}
if string(before) != string(after) {
t.Fatal("unchanged environment password must not rewrite the persisted config")
}
}
func TestNewWebAuthManagerFromEnvironmentRejectsShortPassword(t *testing.T) {
t.Setenv(webAuthPasswordEnvName, "12345")
_, err := newWebAuthManagerFromEnvironment(t.TempDir())
if err == nil || !strings.Contains(err.Error(), webAuthPasswordEnvName) || !strings.Contains(err.Error(), "at least 6") {
t.Fatalf("expected short environment password error, got %v", err)
}
if strings.Contains(err.Error(), "12345") {
t.Fatal("environment password error must not expose the password")
}
}
func TestWebAuthManagerChangePasswordRotatesSession(t *testing.T) {
manager, err := newWebAuthManager(t.TempDir())
if err != nil {

View File

@@ -250,7 +250,7 @@ func New(ctx context.Context, assetFS fs.FS, options Options) (*Server, error) {
appcore.InitializeLifecycle(app, lifecycleCtx)
ai := aiservice.NewService()
aiservice.InitializeLifecycle(ai, lifecycleCtx)
auth, err := newWebAuthManager("")
auth, err := newWebAuthManagerFromEnvironment("")
if err != nil {
return nil, fmt.Errorf("initialize web auth failed: %w", err)
}

View File

@@ -2683,8 +2683,9 @@
"app.settings.web_auth.password.current_placeholder": "Aktuelles Administratorpasswort eingeben",
"app.settings.web_auth.password.description": "Beim Ändern des Passworts wird die aktuelle Web-Sitzung rotiert und andere aktive Sitzungen werden abgemeldet.",
"app.settings.web_auth.password.description_with_code": "Beim Ändern des Passworts wird die aktuelle Web-Sitzung rotiert und andere aktive Sitzungen werden abgemeldet. Wenn 2FA aktiviert ist, geben Sie zusätzlich den aktuellen Code oder einen Wiederherstellungscode ein.",
"app.settings.web_auth.password.managed_by_environment": "Das Administratorpasswort wird über GONAVI_WEB_PASSWORD verwaltet. Erstellen Sie bei Container-Bereitstellungen den Container neu, andernfalls starten Sie den Webserver neu.",
"app.settings.web_auth.password.new_label": "Neues Administratorpasswort",
"app.settings.web_auth.password.new_placeholder": "Mindestens 10 Zeichen",
"app.settings.web_auth.password.new_placeholder": "Mindestens 6 Zeichen",
"app.settings.web_auth.password.save_failed": "Administratorpasswort konnte nicht aktualisiert werden",
"app.settings.web_auth.password.save_success": "Administratorpasswort wurde aktualisiert",
"app.settings.web_auth.password.submit": "Neues Passwort speichern",
@@ -7942,6 +7943,7 @@
"web_auth.error.method_not_allowed": "Methode nicht erlaubt",
"web_auth.error.password_confirmation_mismatch": "Die Passwortbestätigung stimmt nicht überein",
"web_auth.error.password_min_length": "Das Passwort muss mindestens {{count}} Zeichen lang sein",
"web_auth.error.password_managed_by_environment": "Das Administratorpasswort wird über eine Umgebungsvariable verwaltet",
"web_auth.error.password_required": "Admin-Passwort ist erforderlich",
"web_auth.error.retry_after_seconds": ". Bitte in {{seconds}} Sekunden erneut versuchen",
"web_auth.error.setup_info_expired": "Die Initialisierungsdaten sind abgelaufen. Laden Sie die Seite neu und versuchen Sie es erneut.",
@@ -7980,7 +7982,7 @@
"web_auth.page.setup.next": "Weiter",
"web_auth.page.setup.otpauth_label": "otpauth-URI",
"web_auth.page.setup.password_label": "Admin-Passwort",
"web_auth.page.setup.password_placeholder": "Mindestens 10 Zeichen",
"web_auth.page.setup.password_placeholder": "Mindestens 6 Zeichen",
"web_auth.page.setup.qr_alt": "Google-Authenticator-QR-Code",
"web_auth.page.setup.recovery_intro": "Wiederherstellungscodes werden nur dieses eine Mal angezeigt. Bitte offline speichern:",
"web_auth.page.setup.remember_label": "Anmeldung merken (Tage)",

View File

@@ -2683,8 +2683,9 @@
"app.settings.web_auth.password.current_placeholder": "Enter the current admin password",
"app.settings.web_auth.password.description": "Changing the password rotates the current web session and signs out other active sessions.",
"app.settings.web_auth.password.description_with_code": "Changing the password rotates the current web session and signs out other active sessions. When 2FA is enabled, enter the current verification code or a recovery code too.",
"app.settings.web_auth.password.managed_by_environment": "The admin password is managed by GONAVI_WEB_PASSWORD. Recreate the container for container deployments, or restart the Web server otherwise.",
"app.settings.web_auth.password.new_label": "New admin password",
"app.settings.web_auth.password.new_placeholder": "At least 10 characters",
"app.settings.web_auth.password.new_placeholder": "At least 6 characters",
"app.settings.web_auth.password.save_failed": "Failed to update the admin password",
"app.settings.web_auth.password.save_success": "Admin password updated",
"app.settings.web_auth.password.submit": "Save New Password",
@@ -7942,6 +7943,7 @@
"web_auth.error.method_not_allowed": "Method not allowed",
"web_auth.error.password_confirmation_mismatch": "Password confirmation does not match",
"web_auth.error.password_min_length": "Password must be at least {{count}} characters",
"web_auth.error.password_managed_by_environment": "The admin password is managed by an environment variable",
"web_auth.error.password_required": "Admin password is required",
"web_auth.error.retry_after_seconds": ". Try again in {{seconds}} seconds",
"web_auth.error.setup_info_expired": "Setup information expired. Refresh the page and try again.",
@@ -7980,7 +7982,7 @@
"web_auth.page.setup.next": "Next",
"web_auth.page.setup.otpauth_label": "otpauth URI",
"web_auth.page.setup.password_label": "Admin password",
"web_auth.page.setup.password_placeholder": "At least 10 characters",
"web_auth.page.setup.password_placeholder": "At least 6 characters",
"web_auth.page.setup.qr_alt": "Google Authenticator QR code",
"web_auth.page.setup.recovery_intro": "Recovery codes are shown only once. Save them offline:",
"web_auth.page.setup.remember_label": "Remember sign-in (days)",

View File

@@ -2683,8 +2683,9 @@
"app.settings.web_auth.password.current_placeholder": "現在の管理者パスワードを入力",
"app.settings.web_auth.password.description": "パスワードを変更すると現在の Web セッションが更新され、ほかのログイン中セッションは無効になります。",
"app.settings.web_auth.password.description_with_code": "パスワードを変更すると現在の Web セッションが更新され、ほかのログイン中セッションは無効になります。2FA が有効な場合は、現在の認証コードまたはリカバリーコードも入力してください。",
"app.settings.web_auth.password.managed_by_environment": "管理者パスワードは GONAVI_WEB_PASSWORD で管理されています。コンテナ環境ではコンテナを再作成し、それ以外では Web サーバーを再起動してください。",
"app.settings.web_auth.password.new_label": "新しい管理者パスワード",
"app.settings.web_auth.password.new_placeholder": "10 文字以上",
"app.settings.web_auth.password.new_placeholder": "6 文字以上",
"app.settings.web_auth.password.save_failed": "管理者パスワードの更新に失敗しました",
"app.settings.web_auth.password.save_success": "管理者パスワードを更新しました",
"app.settings.web_auth.password.submit": "新しいパスワードを保存",
@@ -7942,6 +7943,7 @@
"web_auth.error.method_not_allowed": "許可されていないメソッドです",
"web_auth.error.password_confirmation_mismatch": "確認用パスワードが一致しません",
"web_auth.error.password_min_length": "パスワードは {{count}} 文字以上必要です",
"web_auth.error.password_managed_by_environment": "管理者パスワードは環境変数で管理されています",
"web_auth.error.password_required": "管理者パスワードを入力してください",
"web_auth.error.retry_after_seconds": "。{{seconds}} 秒後に再試行してください",
"web_auth.error.setup_info_expired": "初期化情報の有効期限が切れました。ページを再読み込みして再試行してください。",
@@ -7980,7 +7982,7 @@
"web_auth.page.setup.next": "次へ",
"web_auth.page.setup.otpauth_label": "otpauth URI",
"web_auth.page.setup.password_label": "管理者パスワード",
"web_auth.page.setup.password_placeholder": "10 文字以上",
"web_auth.page.setup.password_placeholder": "6 文字以上",
"web_auth.page.setup.qr_alt": "Google Authenticator QR コード",
"web_auth.page.setup.recovery_intro": "リカバリーコードは今回のみ表示されます。オフラインで保存してください:",
"web_auth.page.setup.remember_label": "ログインを記憶(日)",

View File

@@ -2683,8 +2683,9 @@
"app.settings.web_auth.password.current_placeholder": "Введите текущий пароль администратора",
"app.settings.web_auth.password.description": "После смены пароля текущая веб-сессия будет обновлена, а остальные активные сессии будут завершены.",
"app.settings.web_auth.password.description_with_code": "После смены пароля текущая веб-сессия будет обновлена, а остальные активные сессии будут завершены. Если включена 2FA, также введите текущий код или код восстановления.",
"app.settings.web_auth.password.managed_by_environment": "Пароль администратора управляется через GONAVI_WEB_PASSWORD. Для контейнерного развёртывания пересоздайте контейнер, иначе перезапустите веб-сервер.",
"app.settings.web_auth.password.new_label": "Новый пароль администратора",
"app.settings.web_auth.password.new_placeholder": "Не менее 10 символов",
"app.settings.web_auth.password.new_placeholder": "Не менее 6 символов",
"app.settings.web_auth.password.save_failed": "Не удалось обновить пароль администратора",
"app.settings.web_auth.password.save_success": "Пароль администратора обновлен",
"app.settings.web_auth.password.submit": "Сохранить новый пароль",
@@ -7942,6 +7943,7 @@
"web_auth.error.method_not_allowed": "Метод не поддерживается",
"web_auth.error.password_confirmation_mismatch": "Подтверждение пароля не совпадает",
"web_auth.error.password_min_length": "Пароль должен содержать не менее {{count}} символов",
"web_auth.error.password_managed_by_environment": "Пароль администратора управляется переменной окружения",
"web_auth.error.password_required": "Требуется пароль администратора",
"web_auth.error.retry_after_seconds": ". Повторите попытку через {{seconds}} с",
"web_auth.error.setup_info_expired": "Данные инициализации устарели. Обновите страницу и попробуйте снова.",
@@ -7980,7 +7982,7 @@
"web_auth.page.setup.next": "Далее",
"web_auth.page.setup.otpauth_label": "otpauth URI",
"web_auth.page.setup.password_label": "Пароль администратора",
"web_auth.page.setup.password_placeholder": "Не менее 10 символов",
"web_auth.page.setup.password_placeholder": "Не менее 6 символов",
"web_auth.page.setup.qr_alt": "QR-код Google Authenticator",
"web_auth.page.setup.recovery_intro": "Коды восстановления показываются только один раз. Сохраните их офлайн:",
"web_auth.page.setup.remember_label": "Запомнить вход (дни)",

View File

@@ -2683,8 +2683,9 @@
"app.settings.web_auth.password.current_placeholder": "输入当前管理员密码",
"app.settings.web_auth.password.description": "修改密码后会轮换当前 Web 会话,其他已登录会话会失效。",
"app.settings.web_auth.password.description_with_code": "修改密码后会轮换当前 Web 会话,其他已登录会话会失效。已启用 2FA 时,还需要输入当前验证码或恢复码。",
"app.settings.web_auth.password.managed_by_environment": "管理员密码由 GONAVI_WEB_PASSWORD 管理。容器部署请重新创建容器,其他部署请重启 Web 服务。",
"app.settings.web_auth.password.new_label": "新管理员密码",
"app.settings.web_auth.password.new_placeholder": "至少 10 位",
"app.settings.web_auth.password.new_placeholder": "至少 6 位",
"app.settings.web_auth.password.save_failed": "修改管理员密码失败",
"app.settings.web_auth.password.save_success": "管理员密码已更新",
"app.settings.web_auth.password.submit": "保存新密码",
@@ -7942,6 +7943,7 @@
"web_auth.error.method_not_allowed": "请求方法不允许",
"web_auth.error.password_confirmation_mismatch": "两次输入的密码不一致",
"web_auth.error.password_min_length": "密码长度至少为 {{count}} 位",
"web_auth.error.password_managed_by_environment": "管理员密码由环境变量管理",
"web_auth.error.password_required": "管理员密码不能为空",
"web_auth.error.retry_after_seconds": ",请在 {{seconds}} 秒后重试",
"web_auth.error.setup_info_expired": "初始化信息已失效,请刷新页面后重试",
@@ -7980,7 +7982,7 @@
"web_auth.page.setup.next": "下一步",
"web_auth.page.setup.otpauth_label": "otpauth URI",
"web_auth.page.setup.password_label": "管理员密码",
"web_auth.page.setup.password_placeholder": "至少 10 位",
"web_auth.page.setup.password_placeholder": "至少 6 位",
"web_auth.page.setup.qr_alt": "Google Authenticator 二维码",
"web_auth.page.setup.recovery_intro": "恢复码仅显示这一次,建议离线保存:",
"web_auth.page.setup.remember_label": "记住登录(天)",

View File

@@ -2683,8 +2683,9 @@
"app.settings.web_auth.password.current_placeholder": "輸入目前管理員密碼",
"app.settings.web_auth.password.description": "修改密碼後會輪換目前 Web 工作階段,其他已登入工作階段會失效。",
"app.settings.web_auth.password.description_with_code": "修改密碼後會輪換目前 Web 工作階段,其他已登入工作階段會失效。啟用 2FA 時,還需要輸入目前驗證碼或恢復碼。",
"app.settings.web_auth.password.managed_by_environment": "管理員密碼由 GONAVI_WEB_PASSWORD 管理。容器部署請重新建立容器,其他部署請重新啟動 Web 服務。",
"app.settings.web_auth.password.new_label": "新管理員密碼",
"app.settings.web_auth.password.new_placeholder": "至少 10 位",
"app.settings.web_auth.password.new_placeholder": "至少 6 位",
"app.settings.web_auth.password.save_failed": "修改管理員密碼失敗",
"app.settings.web_auth.password.save_success": "管理員密碼已更新",
"app.settings.web_auth.password.submit": "儲存新密碼",
@@ -7942,6 +7943,7 @@
"web_auth.error.method_not_allowed": "不允許的請求方法",
"web_auth.error.password_confirmation_mismatch": "兩次輸入的密碼不一致",
"web_auth.error.password_min_length": "密碼長度至少為 {{count}} 位",
"web_auth.error.password_managed_by_environment": "管理員密碼由環境變數管理",
"web_auth.error.password_required": "管理員密碼不能為空",
"web_auth.error.retry_after_seconds": ",請在 {{seconds}} 秒後重試",
"web_auth.error.setup_info_expired": "初始化資訊已失效,請重新整理頁面後再試",
@@ -7980,7 +7982,7 @@
"web_auth.page.setup.next": "下一步",
"web_auth.page.setup.otpauth_label": "otpauth URI",
"web_auth.page.setup.password_label": "管理員密碼",
"web_auth.page.setup.password_placeholder": "至少 10 位",
"web_auth.page.setup.password_placeholder": "至少 6 位",
"web_auth.page.setup.qr_alt": "Google Authenticator QR Code",
"web_auth.page.setup.recovery_intro": "復原碼只會顯示這一次,建議離線保存:",
"web_auth.page.setup.remember_label": "記住登入(天)",

View File

@@ -5,6 +5,8 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DOCKERFILE="${ROOT}/Dockerfile.web-server"
MCP_DOCKERFILE="${ROOT}/Dockerfile.mcp-server"
COMPOSE_FILE="${ROOT}/docker-compose.web-server.yml"
ENV_EXAMPLE="${ROOT}/docker.web-server.env.example"
if [[ ! -f "${DOCKERFILE}" ]]; then
echo "missing Dockerfile.web-server" >&2
@@ -14,6 +16,10 @@ if [[ ! -f "${MCP_DOCKERFILE}" ]]; then
echo "missing Dockerfile.mcp-server" >&2
exit 1
fi
if [[ ! -f "${COMPOSE_FILE}" || ! -f "${ENV_EXAMPLE}" ]]; then
echo "missing web-server Compose or env example" >&2
exit 1
fi
fail() {
echo "validate-web-server-dockerfile: $*" >&2
@@ -62,5 +68,9 @@ grep -Fq '../../../shared/i18n/' "${FRONTEND_CATALOG}" \
assert_target_driver_revisions_generated_before_build "${DOCKERFILE}" "Dockerfile.web-server"
assert_target_driver_revisions_generated_before_build "${MCP_DOCKERFILE}" "Dockerfile.mcp-server"
grep -Fq 'GONAVI_WEB_PASSWORD: ${GONAVI_WEB_PASSWORD:-}' "${COMPOSE_FILE}" \
|| fail "docker-compose.web-server.yml must forward GONAVI_WEB_PASSWORD"
grep -Eq '^GONAVI_WEB_PASSWORD=$' "${ENV_EXAMPLE}" \
|| fail "docker.web-server.env.example must declare an empty GONAVI_WEB_PASSWORD"
echo "validate-web-server-dockerfile: ok"