package webserver import ( "encoding/json" "errors" "fmt" "html" "net/http" "net/url" "strings" "sync" "time" "GoNavi-Wails/shared/i18n" ) const ( webAuthLanguageCookieName = "gonavi_web_lang" webAuthFrontendStorageKey = "lite-db-storage" ) var webAuthPageLocalizers sync.Map type webSetupCompleteRequest struct { SetupToken string `json:"setupToken"` Password string `json:"password"` ConfirmPassword string `json:"confirmPassword"` Code string `json:"code"` EnableTOTP bool `json:"enableTotp"` SessionIdleMinutes int `json:"sessionIdleMinutes"` SessionAbsoluteHours int `json:"sessionAbsoluteHours"` SessionRememberDays int `json:"sessionRememberDays"` } type webLoginRequest struct { Password string `json:"password"` Code string `json:"code"` } type webAuthPasswordChangeRequest struct { CurrentPassword string `json:"currentPassword"` NewPassword string `json:"newPassword"` ConfirmPassword string `json:"confirmPassword"` Code string `json:"code"` } func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, webAuthText(newWebAuthLocalizer(resolveWebAuthLanguage(r)), "web_auth.error.method_not_allowed", nil), http.StatusMethodNotAllowed) return } sessionID, _ := readSessionCookie(r) s.writeJSON(w, http.StatusOK, s.auth.Status(sessionID)) } func (s *Server) handleAuthSettings(w http.ResponseWriter, r *http.Request) { localizer := newWebAuthLocalizer(resolveWebAuthLanguage(r)) if r.Method != http.MethodGet { http.Error(w, webAuthText(localizer, "web_auth.error.method_not_allowed", nil), http.StatusMethodNotAllowed) return } settings, err := s.auth.Settings() if err != nil { status := http.StatusInternalServerError if errors.Is(err, errWebAuthNotConfigured) { status = http.StatusPreconditionFailed } s.writeAuthJSONError(w, status, localizeWebAuthError(localizer, err), 0) return } s.writeJSON(w, http.StatusOK, settings) } func (s *Server) handleSetupBootstrap(w http.ResponseWriter, r *http.Request) { localizer := newWebAuthLocalizer(resolveWebAuthLanguage(r)) if r.Method != http.MethodPost { http.Error(w, webAuthText(localizer, "web_auth.error.method_not_allowed", nil), http.StatusMethodNotAllowed) return } payload, err := s.auth.BeginSetup(r.Host) if err != nil { if err == errWebAuthAlreadyConfigured { s.writeAuthJSONError(w, http.StatusConflict, webAuthText(localizer, "web_auth.error.already_configured", nil), 0) return } s.writeAuthJSONError(w, http.StatusInternalServerError, localizeWebAuthError(localizer, err), 0) return } s.writeJSON(w, http.StatusOK, payload) } func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { localizer := newWebAuthLocalizer(resolveWebAuthLanguage(r)) if r.Method != http.MethodPost { http.Error(w, webAuthText(localizer, "web_auth.error.method_not_allowed", nil), http.StatusMethodNotAllowed) return } defer r.Body.Close() var request webSetupCompleteRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { s.writeAuthJSONError(w, http.StatusBadRequest, webAuthText(localizer, "web_auth.error.invalid_setup_payload", nil), 0) return } if strings.TrimSpace(request.Password) != strings.TrimSpace(request.ConfirmPassword) { s.writeAuthJSONError(w, http.StatusBadRequest, webAuthText(localizer, "web_auth.error.password_confirmation_mismatch", nil), 0) return } cfg, sessionID, err := s.auth.CompleteSetup( request.SetupToken, request.Password, request.Code, request.EnableTOTP, request.SessionIdleMinutes, request.SessionAbsoluteHours, request.SessionRememberDays, ) if err != nil { status := http.StatusBadRequest switch err { case errWebAuthAlreadyConfigured: status = http.StatusConflict case errWebAuthInvalidSetup, errWebAuthSetupExpired: status = http.StatusUnauthorized } s.writeAuthJSONError(w, status, localizeWebAuthError(localizer, err), 0) return } setSessionCookie(w, r, sessionID, cfg, s.auth.now()) s.writeJSON(w, http.StatusOK, map[string]any{ "success": true, }) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { localizer := newWebAuthLocalizer(resolveWebAuthLanguage(r)) if r.Method != http.MethodPost { http.Error(w, webAuthText(localizer, "web_auth.error.method_not_allowed", nil), http.StatusMethodNotAllowed) return } defer r.Body.Close() var request webLoginRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { s.writeAuthJSONError(w, http.StatusBadRequest, webAuthText(localizer, "web_auth.error.invalid_login_payload", nil), 0) return } cfg, sessionID, usedRecoveryCode, retryAfter, err := s.auth.Login(request.Password, request.Code, clientIP(r)) if err != nil { switch err { case errWebAuthNotConfigured: s.writeAuthJSONError(w, http.StatusPreconditionFailed, webAuthText(localizer, "web_auth.error.setup_required", nil), 0) return case errWebAuthRateLimited: s.writeAuthJSONError(w, http.StatusTooManyRequests, webAuthText(localizer, "web_auth.error.too_many_login_attempts", nil), retryAfter) return default: s.writeAuthJSONError(w, http.StatusUnauthorized, webAuthText(localizer, "web_auth.error.invalid_password_or_code", nil), retryAfter) return } } setSessionCookie(w, r, sessionID, cfg, s.auth.now()) s.writeJSON(w, http.StatusOK, map[string]any{ "success": true, "usedRecoveryCode": usedRecoveryCode, }) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost && r.Method != http.MethodGet { http.Error(w, webAuthText(newWebAuthLocalizer(resolveWebAuthLanguage(r)), "web_auth.error.method_not_allowed", nil), http.StatusMethodNotAllowed) return } if sessionID, ok := readSessionCookie(r); ok { s.auth.Logout(sessionID) } clearSessionCookie(w, r) if r.Method == http.MethodGet { http.Redirect(w, r, "/login", http.StatusSeeOther) return } s.writeJSON(w, http.StatusOK, map[string]any{"success": true}) } func (s *Server) handleAuthPasswordChange(w http.ResponseWriter, r *http.Request) { localizer := newWebAuthLocalizer(resolveWebAuthLanguage(r)) if r.Method != http.MethodPost { http.Error(w, webAuthText(localizer, "web_auth.error.method_not_allowed", nil), http.StatusMethodNotAllowed) return } defer r.Body.Close() var request webAuthPasswordChangeRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { s.writeAuthJSONError(w, http.StatusBadRequest, webAuthText(localizer, "web_auth.error.invalid_setup_payload", nil), 0) return } if strings.TrimSpace(request.NewPassword) != strings.TrimSpace(request.ConfirmPassword) { s.writeAuthJSONError(w, http.StatusBadRequest, webAuthText(localizer, "web_auth.error.password_confirmation_mismatch", nil), 0) return } cfg, sessionID, usedRecoveryCode, err := s.auth.ChangePassword(request.CurrentPassword, request.Code, request.NewPassword) if err != nil { status := http.StatusBadRequest switch { case errors.Is(err, errWebAuthNotConfigured): status = http.StatusPreconditionFailed case errors.Is(err, errWebAuthInvalidCredentials): status = http.StatusUnauthorized } s.writeAuthJSONError(w, status, localizeWebAuthError(localizer, err), 0) return } setSessionCookie(w, r, sessionID, cfg, s.auth.now()) s.writeJSON(w, http.StatusOK, map[string]any{ "success": true, "usedRecoveryCode": usedRecoveryCode, "settings": buildWebAuthSettingsSummary(cfg), }) } func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { language := resolveWebAuthLanguage(r) localizer := newWebAuthLocalizer(language) setWebAuthLanguageCookie(w, r, language) sessionID, _ := readSessionCookie(r) status := s.auth.Status(sessionID) if status.Configured && status.Authenticated { http.Redirect(w, r, resolvePostAuthRedirect(r), http.StatusSeeOther) return } if !status.Configured { http.Redirect(w, r, buildAuthRedirectURL("/setup", r.URL.RequestURI()), http.StatusSeeOther) return } s.serveStaticPage(w, r, renderAuthPage( language, webAuthText(localizer, "web_auth.page.login.title", nil), webAuthText(localizer, "web_auth.page.login.subtitle", nil), renderLoginBody(localizer), renderLoginScript(localizer), )) } func (s *Server) handleSetupPage(w http.ResponseWriter, r *http.Request) { language := resolveWebAuthLanguage(r) localizer := newWebAuthLocalizer(language) setWebAuthLanguageCookie(w, r, language) sessionID, _ := readSessionCookie(r) status := s.auth.Status(sessionID) if status.Configured && status.Authenticated { http.Redirect(w, r, resolvePostAuthRedirect(r), http.StatusSeeOther) return } if status.Configured { http.Redirect(w, r, buildAuthRedirectURL("/login", r.URL.RequestURI()), http.StatusSeeOther) return } s.serveStaticPage(w, r, renderAuthPage( language, webAuthText(localizer, "web_auth.page.setup.title", nil), webAuthText(localizer, "web_auth.page.setup.subtitle", nil), renderSetupBody(localizer), renderSetupScript(localizer), )) } func (s *Server) requireWebAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sessionID, ok := readSessionCookie(r) status := s.auth.Status(sessionID) if !status.Configured { clearSessionCookie(w, r) if wantsHTMLResponse(r) { http.Redirect(w, r, buildAuthRedirectURL("/setup", r.URL.RequestURI()), http.StatusSeeOther) return } s.writeAuthJSONError(w, http.StatusPreconditionFailed, webAuthText(newWebAuthLocalizer(resolveWebAuthLanguage(r)), "web_auth.error.setup_required", nil), 0) return } if !ok || !status.Authenticated { clearSessionCookie(w, r) if wantsHTMLResponse(r) { http.Redirect(w, r, buildAuthRedirectURL("/login", r.URL.RequestURI()), http.StatusSeeOther) return } s.writeAuthJSONError(w, http.StatusUnauthorized, webAuthText(newWebAuthLocalizer(resolveWebAuthLanguage(r)), "web_auth.error.auth_required", nil), 0) return } next.ServeHTTP(w, r) }) } func resolveWebAuthLanguage(r *http.Request) i18n.Language { if r != nil { if lang, ok := i18n.NormalizeLanguage(r.URL.Query().Get("lang")); ok { return lang } if cookie, err := r.Cookie(webAuthLanguageCookieName); err == nil { if lang, ok := i18n.NormalizeLanguage(cookie.Value); ok { return lang } } return i18n.ResolveLanguage("", parseAcceptLanguages(r.Header.Get("Accept-Language"))) } return i18n.LanguageEnUS } func parseAcceptLanguages(header string) []string { parts := strings.Split(header, ",") languages := make([]string, 0, len(parts)) for _, part := range parts { value := strings.TrimSpace(strings.SplitN(part, ";", 2)[0]) if value != "" { languages = append(languages, value) } } return languages } func newWebAuthLocalizer(language i18n.Language) *i18n.Localizer { if cached, ok := webAuthPageLocalizers.Load(language); ok { if localizer, ok := cached.(*i18n.Localizer); ok { return localizer } } localizer, err := i18n.NewLocalizer(language) if err != nil { return nil } actual, _ := webAuthPageLocalizers.LoadOrStore(language, localizer) cached, _ := actual.(*i18n.Localizer) return cached } func webAuthText(localizer *i18n.Localizer, key string, params map[string]any) string { if localizer == nil { return key } return localizer.T(key, params) } func webAuthHTML(localizer *i18n.Localizer, key string, params map[string]any) string { return html.EscapeString(webAuthText(localizer, key, params)) } func localizeWebAuthError(localizer *i18n.Localizer, err error) string { if err == nil { return "" } switch { case errors.Is(err, errWebAuthNotConfigured): return webAuthText(localizer, "web_auth.error.setup_required", nil) case errors.Is(err, errWebAuthAlreadyConfigured): return webAuthText(localizer, "web_auth.error.already_configured", nil) case errors.Is(err, errWebAuthSetupExpired): return webAuthText(localizer, "web_auth.error.setup_token_expired", nil) case errors.Is(err, errWebAuthInvalidSetup): return webAuthText(localizer, "web_auth.error.invalid_setup_token", nil) case errors.Is(err, errWebAuthInvalidCredentials): 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) } message := strings.TrimSpace(err.Error()) switch { case strings.HasPrefix(message, "password must be at least "): return webAuthText(localizer, "web_auth.error.password_min_length", map[string]any{ "count": webMinPasswordLength, }) case message == "invalid google authenticator code": return webAuthText(localizer, "web_auth.error.invalid_totp_code", nil) case message == "password is required": return webAuthText(localizer, "web_auth.error.password_required", nil) default: return message } } func setWebAuthLanguageCookie(w http.ResponseWriter, r *http.Request, language i18n.Language) { if w == nil || language == "" { return } http.SetCookie(w, &http.Cookie{ Name: webAuthLanguageCookieName, Value: string(language), Path: "/", MaxAge: 365 * 24 * 60 * 60, SameSite: http.SameSiteLaxMode, Secure: r != nil && r.TLS != nil, }) } func wantsHTMLResponse(r *http.Request) bool { if r == nil || r.Method != http.MethodGet { return false } return strings.Contains(strings.ToLower(r.Header.Get("Accept")), "text/html") } func resolvePostAuthRedirect(r *http.Request) string { if r == nil { return "/" } next := strings.TrimSpace(r.URL.Query().Get("next")) if next == "" { return "/" } if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") { return "/" } return next } func buildAuthRedirectURL(target string, next string) string { values := url.Values{} normalizedNext := strings.TrimSpace(next) if normalizedNext != "" && strings.HasPrefix(normalizedNext, "/") && !strings.HasPrefix(normalizedNext, "//") { values.Set("next", normalizedNext) } if encoded := values.Encode(); encoded != "" { return target + "?" + encoded } return target } func (s *Server) writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func (s *Server) writeAuthJSONError(w http.ResponseWriter, status int, message string, retryAfter time.Duration) { response := map[string]any{ "error": strings.TrimSpace(message), } if retryAfter > 0 { seconds := int(retryAfter.Seconds()) if seconds <= 0 { seconds = 1 } w.Header().Set("Retry-After", fmt.Sprintf("%d", seconds)) response["retryAfterSeconds"] = seconds } s.writeJSON(w, status, response) } func (s *Server) serveStaticPage(w http.ResponseWriter, r *http.Request, payload string) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Pragma", "no-cache") w.Header().Set("Content-Type", "text/html; charset=utf-8") http.ServeContent(w, r, "index.html", time.Time{}, strings.NewReader(payload)) } func renderAuthPage(language i18n.Language, title string, subtitle string, body string, script string) string { return ` ` + html.EscapeString(title) + `

` + html.EscapeString(title) + `

` + html.EscapeString(subtitle) + `

` + body + `
` } func renderAuthBootstrapScript(language i18n.Language) string { return ` const __gonaviWebAuthPage = ` + mustJSON(map[string]string{ "language": string(language), "cookieName": webAuthLanguageCookieName, "storageKey": webAuthFrontendStorageKey, }) + `; (function syncAuthPageLanguage() { const normalizeLanguage = (value) => { if (typeof value !== 'string') return null; const normalized = value.trim().replace(/_/g, '-').toLowerCase(); if (!normalized) return null; if (normalized === 'zh-tw' || normalized === 'zh-hk' || normalized === 'zh-mo') return 'zh-TW'; if (normalized === 'zh' || normalized === 'zh-cn' || normalized === 'zh-sg') return 'zh-CN'; if (normalized === 'en-us' || normalized.startsWith('en-')) return 'en-US'; if (normalized === 'ja' || normalized.startsWith('ja-')) return 'ja-JP'; if (normalized === 'de' || normalized.startsWith('de-')) return 'de-DE'; if (normalized === 'ru' || normalized.startsWith('ru-')) return 'ru-RU'; return null; }; const resolveStoredLanguage = () => { try { if (!window.localStorage) return null; const payload = window.localStorage.getItem(__gonaviWebAuthPage.storageKey); if (!payload) return null; const parsed = JSON.parse(payload); const state = parsed && typeof parsed === 'object' && parsed.state && typeof parsed.state === 'object' ? parsed.state : parsed; const preference = state && typeof state === 'object' ? state.languagePreference : null; if (preference === 'system') { const systemLanguages = Array.isArray(navigator.languages) && navigator.languages.length > 0 ? navigator.languages : [navigator.language]; for (const candidate of systemLanguages) { const resolved = normalizeLanguage(candidate); if (resolved) return resolved; } return null; } return normalizeLanguage(preference); } catch (_) { return null; } }; const syncCookie = (language) => { document.cookie = __gonaviWebAuthPage.cookieName + '=' + encodeURIComponent(language) + '; Path=/; Max-Age=31536000; SameSite=Lax'; }; const storedLanguage = resolveStoredLanguage(); if (!storedLanguage || storedLanguage === __gonaviWebAuthPage.language) { syncCookie(__gonaviWebAuthPage.language); return; } syncCookie(storedLanguage); const url = new URL(window.location.href); if (url.searchParams.get('lang') !== storedLanguage) { url.searchParams.set('lang', storedLanguage); window.location.replace(url.toString()); } })(); ` } func renderLoginBody(localizer *i18n.Localizer) string { return `

` + webAuthHTML(localizer, "web_auth.page.login.heading", nil) + `

` + webAuthHTML(localizer, "web_auth.page.login.description", nil) + `

` + webAuthHTML(localizer, "web_auth.page.login.security_title", nil) + `

` } func renderLoginScript(localizer *i18n.Localizer) string { return ` const i18n = ` + mustJSON(map[string]string{ "loginFailed": webAuthText(localizer, "web_auth.error.login_failed", nil), "loadStatusFailed": webAuthText(localizer, "web_auth.error.load_status_failed", nil), "retryAfter": webAuthText(localizer, "web_auth.error.retry_after_seconds", nil), }) + `; const errorEl = document.getElementById('error'); const formEl = document.getElementById('login-form'); const submitEl = document.getElementById('submit'); const codeWrapEl = document.getElementById('code-wrap'); const nextTarget = new URLSearchParams(window.location.search).get('next') || '/'; function showError(message) { errorEl.textContent = message || i18n.loginFailed; errorEl.style.display = 'block'; } function clearError() { errorEl.textContent = ''; errorEl.style.display = 'none'; } async function loadStatus() { const response = await fetch('` + internalRoutePrefix + `/auth/status', { credentials: 'same-origin', cache: 'no-store' }); const payload = await response.json().catch(() => ({})); if (!response.ok) { showError(payload.error || i18n.loadStatusFailed); return; } if (!payload.configured) { window.location.replace('/setup?next=' + encodeURIComponent(nextTarget)); return; } codeWrapEl.hidden = payload.totpEnabled !== true; } formEl.addEventListener('submit', async (event) => { event.preventDefault(); clearError(); submitEl.disabled = true; try { const response = await fetch('` + internalRoutePrefix + `/auth/login', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: document.getElementById('password').value || '', code: document.getElementById('code').value || '' }) }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.error) { const retryAfter = Number(payload.retryAfterSeconds || 0); const suffix = retryAfter > 0 ? i18n.retryAfter.replace('{{seconds}}', String(retryAfter)) : ''; showError((payload.error || i18n.loginFailed) + suffix); submitEl.disabled = false; return; } window.location.replace(nextTarget); } catch (_) { showError(i18n.loginFailed); submitEl.disabled = false; } }); void loadStatus();` } func renderSetupBody(localizer *i18n.Localizer) string { return `
` + webAuthHTML(localizer, "web_auth.page.setup.intro", nil) + `

` + webAuthHTML(localizer, "web_auth.page.setup.admin_title", nil) + `

` + webAuthHTML(localizer, "web_auth.page.setup.step_admin_hint", nil) + `
` } func renderSetupScript(localizer *i18n.Localizer) string { return ` const i18n = ` + mustJSON(map[string]string{ "initFailed": webAuthText(localizer, "web_auth.error.init_failed", nil), "initInfoFailed": webAuthText(localizer, "web_auth.error.init_info_failed", nil), "setupInfoExpired": webAuthText(localizer, "web_auth.error.setup_info_expired", nil), "copyManual": webAuthText(localizer, "web_auth.error.copy_manual", nil), "passwordRequired": webAuthText(localizer, "web_auth.error.password_required", nil), "passwordTooShort": webAuthText(localizer, "web_auth.error.password_min_length", map[string]any{"count": webMinPasswordLength}), "passwordConfirmMismatch": webAuthText(localizer, "web_auth.error.password_confirmation_mismatch", nil), "totpCodeRequired": webAuthText(localizer, "web_auth.error.totp_code_required", nil), }) + `; const errorEl = document.getElementById('error'); const setupFormEl = document.getElementById('setup-form'); const passwordEl = document.getElementById('password'); const confirmPasswordEl = document.getElementById('confirm-password'); const codeEl = document.getElementById('code'); const submitEl = document.getElementById('submit'); const nextStepEl = document.getElementById('next-step'); const backStepEl = document.getElementById('back-step'); const enableTotpEl = document.getElementById('enable-totp'); const totpConfigEl = document.getElementById('totp-config'); const totpDisabledNoteEl = document.getElementById('totp-disabled-note'); const stepButtons = Array.from(document.querySelectorAll('[data-step-target]')); const stepPanels = Array.from(document.querySelectorAll('[data-step-panel]')); const nextTarget = new URLSearchParams(window.location.search).get('next') || '/'; let bootstrapState = null; let currentStep = 0; const lastStepIndex = stepPanels.length - 1; function showError(message) { errorEl.textContent = message || i18n.initFailed; errorEl.style.display = 'block'; } function clearError() { errorEl.textContent = ''; errorEl.style.display = 'none'; } function toggleTotpSection() { const enabled = enableTotpEl.checked; totpConfigEl.hidden = !enabled; totpDisabledNoteEl.hidden = enabled; } function getWizardPath() { return enableTotpEl.checked ? [0, 1, 2] : [0, 2]; } function normalizeCurrentStep() { const path = getWizardPath(); if (path.includes(currentStep)) { return; } currentStep = path.find((step) => step > currentStep) ?? path[path.length - 1]; } function getPathIndex(step) { return getWizardPath().indexOf(step); } function getAdjacentStep(step, direction) { const path = getWizardPath(); const currentIndex = path.indexOf(step); if (currentIndex === -1) { return path[0]; } const targetIndex = Math.max(0, Math.min(path.length - 1, currentIndex + direction)); return path[targetIndex]; } async function copyText(value) { const text = String(value || ''); if (!text) return; if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return; } window.prompt(i18n.copyManual, text); } function updateWizard() { normalizeCurrentStep(); const path = getWizardPath(); const currentPathIndex = getPathIndex(currentStep); const lastActiveStep = path[path.length - 1]; let visibleStepIndex = 1; stepPanels.forEach((panel) => { const step = Number(panel.dataset.stepPanel || 0); panel.hidden = step !== currentStep; }); stepButtons.forEach((button) => { const step = Number(button.dataset.stepTarget || 0); const inPath = path.includes(step); const stepIndexEl = button.querySelector('.wizard-step-index'); button.hidden = !inPath; if (!inPath) { button.disabled = true; button.classList.remove('is-active', 'is-completed'); button.removeAttribute('aria-current'); return; } if (stepIndexEl) { stepIndexEl.textContent = String(visibleStepIndex); } visibleStepIndex += 1; const pathIndex = getPathIndex(step); button.disabled = pathIndex > currentPathIndex; button.classList.toggle('is-active', step === currentStep); button.classList.toggle('is-completed', pathIndex < currentPathIndex); if (step === currentStep) { button.setAttribute('aria-current', 'step'); } else { button.removeAttribute('aria-current'); } }); backStepEl.hidden = currentStep === 0; nextStepEl.hidden = currentStep >= lastActiveStep; submitEl.hidden = currentStep !== lastActiveStep; } function validateStep(stepIndex) { if (stepIndex === 0) { const password = String(passwordEl.value || '').trim(); const confirmPassword = String(confirmPasswordEl.value || '').trim(); if (!password) { showError(i18n.passwordRequired); return false; } if (password.length < ` + fmt.Sprintf("%d", webMinPasswordLength) + `) { showError(i18n.passwordTooShort); return false; } if (password !== confirmPassword) { showError(i18n.passwordConfirmMismatch); return false; } } if (stepIndex === 1 && enableTotpEl.checked && !String(codeEl.value || '').trim()) { showError(i18n.totpCodeRequired); return false; } return true; } function goToStep(nextStep, options) { const validateCurrent = !options || options.validateCurrent !== false; const path = getWizardPath(); const requestedStep = Number(nextStep || 0); const boundedStep = path.includes(requestedStep) ? requestedStep : (path.find((step) => step >= requestedStep) ?? path[path.length - 1]); if (boundedStep > currentStep && validateCurrent && !validateStep(currentStep)) { return; } clearError(); currentStep = boundedStep; updateWizard(); } async function bootstrapSetup() { try { clearError(); const statusResponse = await fetch('` + internalRoutePrefix + `/auth/status', { credentials: 'same-origin', cache: 'no-store' }); const statusPayload = await statusResponse.json().catch(() => ({})); if (statusPayload.configured) { const target = statusPayload.authenticated ? nextTarget : '/login?next=' + encodeURIComponent(nextTarget); window.location.replace(target); return; } const response = await fetch('` + internalRoutePrefix + `/auth/setup/bootstrap', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: '{}' }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.error) { showError(payload.error || i18n.initInfoFailed); return; } bootstrapState = payload; document.getElementById('issuer').value = payload.issuer || ''; document.getElementById('account-name').value = payload.accountName || ''; document.getElementById('secret').value = payload.secret || ''; document.getElementById('otpauth-url').value = payload.otpauthUrl || ''; document.getElementById('totp-qr-code').src = payload.qrCodeDataUrl || ''; document.getElementById('idle-minutes').value = String(payload.sessionIdleMinutes || 30); document.getElementById('absolute-hours').value = String(payload.sessionAbsoluteHours || 168); document.getElementById('remember-days').value = String(payload.sessionRememberDays || 7); const codesEl = document.getElementById('recovery-codes'); codesEl.innerHTML = ''; (payload.recoveryCodes || []).forEach((item) => { const li = document.createElement('li'); li.className = 'code-item'; li.textContent = item; codesEl.appendChild(li); }); updateWizard(); } catch (_) { showError(i18n.initInfoFailed); return; } } document.getElementById('copy-secret').addEventListener('click', () => { void copyText(document.getElementById('secret').value); }); document.getElementById('copy-uri').addEventListener('click', () => { void copyText(document.getElementById('otpauth-url').value); }); enableTotpEl.addEventListener('change', () => { toggleTotpSection(); updateWizard(); }); stepButtons.forEach((button) => { button.addEventListener('click', () => { const targetStep = Number(button.dataset.stepTarget || 0); if (getPathIndex(targetStep) !== -1 && getPathIndex(targetStep) <= getPathIndex(currentStep)) { goToStep(targetStep, { validateCurrent: false }); } }); }); backStepEl.addEventListener('click', () => { goToStep(getAdjacentStep(currentStep, -1), { validateCurrent: false }); }); nextStepEl.addEventListener('click', () => { goToStep(getAdjacentStep(currentStep, 1)); }); toggleTotpSection(); updateWizard(); setupFormEl.addEventListener('submit', async (event) => { event.preventDefault(); if (currentStep !== getWizardPath()[getWizardPath().length - 1]) { goToStep(getAdjacentStep(currentStep, 1)); return; } clearError(); if (!validateStep(0)) { currentStep = 0; updateWizard(); return; } if (!validateStep(1)) { currentStep = 1; updateWizard(); return; } if (!bootstrapState || !bootstrapState.setupToken) { showError(i18n.setupInfoExpired); return; } submitEl.disabled = true; try { const response = await fetch('` + internalRoutePrefix + `/auth/setup/complete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ setupToken: bootstrapState.setupToken, password: document.getElementById('password').value || '', confirmPassword: document.getElementById('confirm-password').value || '', code: document.getElementById('code').value || '', enableTotp: enableTotpEl.checked, sessionIdleMinutes: Number(document.getElementById('idle-minutes').value || 30), sessionAbsoluteHours: Number(document.getElementById('absolute-hours').value || 168), sessionRememberDays: Number(document.getElementById('remember-days').value || 7) }) }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.error) { showError(payload.error || i18n.initFailed); submitEl.disabled = false; return; } window.location.replace(nextTarget); } catch (_) { showError(i18n.initFailed); submitEl.disabled = false; } }); void bootstrapSetup();` } func mustJSON(value any) string { payload, err := json.Marshal(value) if err != nil { return "{}" } return string(payload) } func withSecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "same-origin") w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") next.ServeHTTP(w, r) }) }