From e8605f15a20c368680bef0159c0d09790955b2a5 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 17 Jul 2026 07:02:11 +0800 Subject: [PATCH] feat(web): complete localized sync server console --- README.md | 51 ++- internal/server/handlers_admin.go | 5 +- internal/server/handlers_api.go | 2 +- internal/server/handlers_auth.go | 3 + internal/server/handlers_web_user.go | 59 +++- internal/server/health.go | 58 +++- internal/server/locale.go | 162 ++++++++- internal/server/rate.go | 37 +- internal/server/routes.go | 1 + internal/server/server.go | 34 +- internal/server/server_test.go | 8 +- internal/server/web/static/app.css | 10 +- internal/server/web/static/app.js | 33 +- internal/server/web/templates/admin.html | 29 -- .../server/web/templates/admin_audit.html | 9 + .../server/web/templates/admin_dashboard.html | 10 + .../server/web/templates/admin_devices.html | 9 + .../web/templates/admin_diagnostics.html | 7 + .../server/web/templates/admin_login.html | 2 +- internal/server/web/templates/admin_nav.html | 14 + .../web/templates/admin_password_result.html | 8 + .../server/web/templates/admin_settings.html | 47 ++- .../server/web/templates/admin_storage.html | 10 + .../server/web/templates/admin_users.html | 9 + .../server/web/templates/admin_vaults.html | 7 + internal/server/web/templates/confirm.html | 2 +- internal/server/web/templates/dashboard.html | 7 +- internal/server/web/templates/forgot.html | 2 +- internal/server/web/templates/home.html | 2 +- internal/server/web/templates/layout.html | 3 +- internal/server/web/templates/login.html | 2 +- internal/server/web/templates/register.html | 2 +- internal/server/web/templates/reset.html | 2 +- .../server/web/templates/unavailable.html | 2 + .../server/web/templates/vault_detail.html | 10 +- internal/server/web_admin.go | 324 ++++++++++++++++-- internal/server/web_locale.go | 41 ++- internal/server/web_locale_test.go | 317 ++++++++++++++++- internal/server/web_render.go | 146 +++++++- scripts/smoke-web-browser.mjs | 152 ++++++++ scripts/smoke-web.sh | 18 +- 41 files changed, 1494 insertions(+), 162 deletions(-) delete mode 100644 internal/server/web/templates/admin.html create mode 100644 internal/server/web/templates/admin_audit.html create mode 100644 internal/server/web/templates/admin_dashboard.html create mode 100644 internal/server/web/templates/admin_devices.html create mode 100644 internal/server/web/templates/admin_diagnostics.html create mode 100644 internal/server/web/templates/admin_nav.html create mode 100644 internal/server/web/templates/admin_password_result.html create mode 100644 internal/server/web/templates/admin_storage.html create mode 100644 internal/server/web/templates/admin_users.html create mode 100644 internal/server/web/templates/admin_vaults.html create mode 100644 internal/server/web/templates/unavailable.html create mode 100755 scripts/smoke-web-browser.mjs diff --git a/README.md b/README.md index 6c1c893..9a69e60 100644 --- a/README.md +++ b/README.md @@ -230,32 +230,61 @@ The server embeds its public, account, and administrator interface in the Go binary. It has no CDN, npm build, external font, or remote analytics dependency. `/` is a localized public page; `/login`, `/register`, `/forgot`, and `/reset` use post/redirect/get flows. `/dashboard` lets a signed-in user -review their own devices and revoke one only after entering their password. +review and search only their own devices, see confirmation state and connection +times, and revoke one only after entering their password. The reusable layout, +templates, CSS, JavaScript, and local SVG live below `internal/server/web/` and +are embedded with `go:embed`; there is no separate frontend build. `/admin/login` opens the administrator console. Its sidebar provides overview, -users, devices, vaults, storage, audit, SMTP settings, and diagnostics. User -blocking is immediate; device revocation and SMTP changes require the current +users, devices, vaults, storage, audit, SMTP settings, and diagnostics. Lists +use bounded server-side search, filters, whitelisted sort order, and pagination. +Administrators can create, edit, confirm, block, reset, and delete users; revoke +devices; and permanently remove only a previously revoked device. A browser +password reset generates a random password and exposes it once on a `no-store` +page; it is never placed in the URL, audit log, cookies, or database plaintext. +Destructive +browser actions use the shared local confirmation dialog. Blocking, credential +changes, device actions, cleanup, and SMTP changes require the current administrator password again. Admin HTML is deliberately a normal server rendered control plane; the existing `/admin/api/...` endpoints remain for automation. The locale resolver uses the `verstak_locale` HttpOnly/Lax cookie first. Its values are `ru`, `en`, or `system`; `system` uses `Accept-Language`, then -`web.default_locale`, then English. The choice survives login and logout. -Registration is controlled by `web.allow_registration`; when disabled the -public registration page does not expose account creation. +`web.default_locale`, then English. The choice survives login and logout. A +separate short-lived HttpOnly form token protects the language chooser and all +anonymous browser POST forms; it is distinct from the server-side session CSRF +token. Registration is controlled by `web.allow_registration`; when disabled +the public registration page does not expose account creation. -The admin console also provides bounded user/device/audit lists, a vault detail -view with aggregates only (never file payloads), safe retention cleanup, and a -sanitized diagnostics download. General web settings are stored in the existing -`config.yml`; SMTP passwords are not returned to a browser form. +The overview reports operational counts and readiness warnings. Vault details +show only metadata (devices, sequence, operation count, activity, and blob +usage), never file contents or operation `payload_json`. Diagnostics download +is sanitized: it excludes paths, tokens, passwords, hashes, and payloads. +General web settings are stored in the existing `config.yml`; public URL and +registration policy can be changed there through the console, while transport +limits remain read-only. SMTP passwords are never returned to a browser form. All browser mutations use POST and validate a server-side session plus CSRF -token. The server returns security headers including a restrictive CSP, +token; anonymous forms use the separate public-form token described above. +The server returns security headers including a restrictive CSP, `frame-ancestors 'none'`, `nosniff`, and a same-origin referrer policy. The console must still be deployed behind the HTTPS reverse proxy described below: secure cookies are enabled when HTTPS is detected through a trusted proxy. +Run the local interactive browser smoke (Chromium plus Node's built-in +`undici`; no npm install) with: + +```bash +./scripts/smoke-web.sh +``` + +It exercises language switching, admin login/navigation, temporary-user +creation, block/unblock confirmation, device pairing/revocation, filtering, +logout, and desktop/mobile screenshots. It starts an isolated temporary server +and removes its data and screenshots on exit; it is not a reverse-proxy or +production-deployment test. + Sync operations are generic records with `entity_type`, `entity_id`, `op_type`, `payload_json`, `device_id`, and sequencing metadata. A pairing token is bound to one user and vault. The server derives the stored device ID and operation diff --git a/internal/server/handlers_admin.go b/internal/server/handlers_admin.go index baa9b5b..34702c3 100644 --- a/internal/server/handlers_admin.go +++ b/internal/server/handlers_admin.go @@ -22,9 +22,12 @@ func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) { s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/login") return } + if !s.requirePublicWebMutation(w, r, "/admin/login") { + return + } user := r.FormValue("username") pass := r.FormValue("password") - if !s.allowRate(w, r, "login", user) { + if !s.allowWebRate(w, r, "login", user, "/admin/login") { return } if !s.cfg.CheckAdmin(user, pass) { diff --git a/internal/server/handlers_api.go b/internal/server/handlers_api.go index 01c65fd..a54c919 100644 --- a/internal/server/handlers_api.go +++ b/internal/server/handlers_api.go @@ -15,7 +15,7 @@ import ( func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/api/") { - s.renderPageStatus(w, r, "error", webPage{Title: "error.label", Heading: "error.badRequest", Message: "error.tryAgain", BackURL: "/"}, http.StatusNotFound) + s.renderPageStatus(w, r, "error", webPage{Title: "error.notFound", Heading: "error.notFound", Message: "error.notFound", BackURL: "/"}, http.StatusNotFound) return } jsonErr(w, 404, "not found") diff --git a/internal/server/handlers_auth.go b/internal/server/handlers_auth.go index 2e1216e..bd6bef3 100644 --- a/internal/server/handlers_auth.go +++ b/internal/server/handlers_auth.go @@ -102,6 +102,9 @@ func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) { } tokenStr = req.Token } else if err := r.ParseForm(); err == nil { + if !s.requirePublicWebMutation(w, r, "/login") { + return + } tokenStr = r.FormValue("token") } else { jsonErrCode(w, http.StatusBadRequest, "invalid_request", "invalid form") diff --git a/internal/server/handlers_web_user.go b/internal/server/handlers_web_user.go index 4466df6..51e1be6 100644 --- a/internal/server/handlers_web_user.go +++ b/internal/server/handlers_web_user.go @@ -37,12 +37,15 @@ func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) { s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/register") return } + if !s.requirePublicWebMutation(w, r, "/register") { + return + } username, email, password := strings.TrimSpace(r.FormValue("username")), strings.TrimSpace(r.FormValue("email")), r.FormValue("password") if username == "" || email == "" || password == "" { s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle", Flash: "error.allFieldsRequired"}) return } - if !s.allowRate(w, r, "register", email) { + if !s.allowWebRate(w, r, "register", email, "/register") { return } if err := validatePassword(password); err != "" { @@ -104,12 +107,15 @@ func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) { s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/forgot") return } + if !s.requirePublicWebMutation(w, r, "/forgot") { + return + } email := strings.ToLower(strings.TrimSpace(r.FormValue("email"))) if email == "" { s.renderPage(w, r, "forgot", webPage{Title: "auth.forgotTitle", Flash: "error.emailRequired"}) return } - if !s.allowRate(w, r, "forgot", email) { + if !s.allowWebRate(w, r, "forgot", email, "/forgot") { return } var userID string @@ -163,12 +169,15 @@ func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) { s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/forgot") return } + if !s.requirePublicWebMutation(w, r, "/forgot") { + return + } token, password, confirm := r.FormValue("token"), r.FormValue("password"), r.FormValue("confirm") if token == "" || password == "" || confirm == "" { s.renderPage(w, r, "reset", webPage{Title: "auth.resetTitle", Token: token, Flash: "error.allFieldsRequired"}) return } - if !s.allowRate(w, r, "reset", "") { + if !s.allowWebRate(w, r, "reset", "", "/forgot") { return } if err := validatePassword(password); err != "" { @@ -205,8 +214,11 @@ func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) { s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login") return } + if !s.requirePublicWebMutation(w, r, "/login") { + return + } login, password := strings.TrimSpace(r.FormValue("username")), r.FormValue("password") - if !s.allowRate(w, r, "login", login) { + if !s.allowWebRate(w, r, "login", login, "/login") { return } var userID, hash string @@ -239,11 +251,20 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) { return } var username, email string - if err := s.db.QueryRow("SELECT username, email FROM server_users WHERE id=?", userID).Scan(&username, &email); err != nil { + var confirmed int + if err := s.db.QueryRow("SELECT username, email, confirmed FROM server_users WHERE id=?", userID).Scan(&username, &email, &confirmed); err != nil { s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/") return } - rows, err := s.db.Query(`SELECT d.id, d.name, COALESCE(d.vault_id,''), COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at FROM server_devices d JOIN server_user_devices ud ON ud.device_id=d.id WHERE ud.user_id=? ORDER BY d.created_at DESC`, userID) + query := strings.TrimSpace(r.URL.Query().Get("q")) + where := " WHERE ud.user_id=?" + args := []interface{}{userID} + if query != "" { + like := "%" + query + "%" + where += " AND (d.name LIKE ? OR d.vault_id LIKE ? OR d.client_version LIKE ?)" + args = append(args, like, like, like) + } + rows, err := s.db.Query(`SELECT d.id, d.name, COALESCE(d.vault_id,''), COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at FROM server_devices d JOIN server_user_devices ud ON ud.device_id=d.id`+where+` ORDER BY d.created_at DESC`, args...) if err != nil { s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/") return @@ -267,11 +288,16 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) { s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/") return } - flash := r.URL.Query().Get("error") - if flash != "error.invalidCredentials" { + flash := r.URL.Query().Get("flash") + flashError := false + if flash == "" { + flash = r.URL.Query().Get("error") + flashError = flash != "" + } + if flash != "error.invalidCredentials" && flash != "user.deviceRevoked" { flash = "" } - s.renderPage(w, r, "dashboard", webPage{Title: "user.account", UserName: username, Email: email, Devices: devices, Flash: flash}) + s.renderPage(w, r, "dashboard", webPage{Title: "user.account", UserName: username, Email: email, UserConfirmed: confirmed != 0, Devices: devices, Flash: flash, FlashError: flashError, List: webList{Query: query}}) } func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) { @@ -353,12 +379,21 @@ func (s *Server) handleUserWebDeviceAction(w http.ResponseWriter, r *http.Reques } password = req.Password } - if password == "" || !s.allowRate(w, r, "auth-test", session.SubjectID) { - if password == "" { + if password == "" { + if formRequest { + http.Redirect(w, r, "/dashboard?error=error.invalidCredentials", http.StatusSeeOther) + } else { jsonErrCode(w, http.StatusBadRequest, "invalid_request", "password required") } return } + if formRequest { + if !s.allowWebRate(w, r, "auth-test", session.SubjectID, "/dashboard") { + return + } + } else if !s.allowRate(w, r, "auth-test", session.SubjectID) { + return + } var hash string if err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", session.SubjectID).Scan(&hash); err != nil { jsonInternalError(w, err) @@ -387,7 +422,7 @@ func (s *Server) handleUserWebDeviceAction(w http.ResponseWriter, r *http.Reques } s.auditLog("device_revoked", session.SubjectID, deviceID, s.clientIP(r), "device revoked from web dashboard") if formRequest { - http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + http.Redirect(w, r, "/dashboard?flash=user.deviceRevoked", http.StatusSeeOther) return } jsonOK(w, map[string]string{"status": "revoked"}) diff --git a/internal/server/health.go b/internal/server/health.go index 5399c6c..fc05158 100644 --- a/internal/server/health.go +++ b/internal/server/health.go @@ -6,6 +6,7 @@ import ( "io/fs" "os" "path/filepath" + "strings" "time" ) @@ -64,33 +65,65 @@ func (s *Server) blobStorageWritable() bool { // ServerStats is intentionally independent from the web UI so a future // admin panel can expose operational data without coupling to templates. type ServerStats struct { - Users int `json:"users"` - ActiveDevices int `json:"active_devices"` - RevokedDevices int `json:"revoked_devices"` - Vaults int `json:"vaults"` - Operations int `json:"operations"` - DatabaseBytes int64 `json:"database_bytes"` - BlobBytes int64 `json:"blob_bytes"` - LastSyncAt string `json:"last_sync_activity"` + Users int `json:"users"` + ActiveUsers int `json:"active_users"` + BlockedUsers int `json:"blocked_users"` + UnconfirmedUsers int `json:"unconfirmed_users"` + ActiveDevices int `json:"active_devices"` + RevokedDevices int `json:"revoked_devices"` + Vaults int `json:"vaults"` + Operations int `json:"operations"` + Operations24h int `json:"operations_24h"` + Blobs int `json:"blobs"` + BlobReferences int `json:"blob_references"` + OrphanBlobs int `json:"orphan_blobs"` + TempUploads int `json:"temp_uploads"` + ExpiredSessions int `json:"expired_sessions"` + ExpiredTokens int `json:"expired_email_tokens"` + AuditEvents int `json:"audit_events"` + DatabaseBytes int64 `json:"database_bytes"` + BlobBytes int64 `json:"blob_bytes"` + LastSyncAt string `json:"last_sync_activity"` + LastCleanupAt string `json:"last_cleanup_at"` } func (s *Server) Stats(ctx context.Context) (ServerStats, error) { var stats ServerStats + now := time.Now().UTC() queries := []struct { query string target *int }{ {"SELECT COUNT(*) FROM server_users", &stats.Users}, + {"SELECT COUNT(*) FROM server_users WHERE confirmed=1 AND blocked=0", &stats.ActiveUsers}, + {"SELECT COUNT(*) FROM server_users WHERE blocked=1", &stats.BlockedUsers}, + {"SELECT COUNT(*) FROM server_users WHERE confirmed=0", &stats.UnconfirmedUsers}, {"SELECT COUNT(*) FROM server_devices WHERE COALESCE(revoked_at, '') = ''", &stats.ActiveDevices}, {"SELECT COUNT(*) FROM server_devices WHERE COALESCE(revoked_at, '') != ''", &stats.RevokedDevices}, {"SELECT COUNT(DISTINCT user_id || ':' || vault_id) FROM server_devices WHERE COALESCE(user_id,'') != '' AND COALESCE(vault_id,'') != ''", &stats.Vaults}, {"SELECT COUNT(*) FROM server_ops", &stats.Operations}, + {"SELECT COUNT(*) FROM server_blobs", &stats.Blobs}, + {"SELECT COUNT(*) FROM server_blob_refs", &stats.BlobReferences}, + {"SELECT COUNT(*) FROM server_blobs b WHERE NOT EXISTS (SELECT 1 FROM server_blob_refs r WHERE r.sha256=b.sha256)", &stats.OrphanBlobs}, + {"SELECT COUNT(*) FROM server_audit_log", &stats.AuditEvents}, } for _, query := range queries { if err := s.db.QueryRowContext(ctx, query.query).Scan(query.target); err != nil { return ServerStats{}, err } } + if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_ops WHERE created_at >= ?", now.Add(-24*time.Hour).Format(time.RFC3339)).Scan(&stats.Operations24h); err != nil { + return ServerStats{}, err + } + if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_sessions WHERE expires_at <= ?", now.Format(time.RFC3339)).Scan(&stats.ExpiredSessions); err != nil { + return ServerStats{}, err + } + if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_email_tokens WHERE expires_at <= ?", now.Format(time.RFC3339)).Scan(&stats.ExpiredTokens); err != nil { + return ServerStats{}, err + } + if err := s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(created_at),'') FROM server_audit_log WHERE event_type='retention_cleanup'").Scan(&stats.LastCleanupAt); err != nil { + return ServerStats{}, err + } if err := s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(last_seen), '') FROM server_devices").Scan(&stats.LastSyncAt); err != nil { return ServerStats{}, err } @@ -112,5 +145,14 @@ func (s *Server) Stats(ctx context.Context) (ServerStats, error) { }); err != nil && !os.IsNotExist(err) { return ServerStats{}, fmt.Errorf("blob storage stats: %w", err) } + if entries, err := os.ReadDir(s.blobsDir); err == nil { + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".upload-") { + stats.TempUploads++ + } + } + } else if !os.IsNotExist(err) { + return ServerStats{}, fmt.Errorf("temporary upload stats: %w", err) + } return stats, nil } diff --git a/internal/server/locale.go b/internal/server/locale.go index 9ba4227..c6fe5e2 100644 --- a/internal/server/locale.go +++ b/internal/server/locale.go @@ -77,6 +77,12 @@ var _translations = map[string]map[string]string{ "error.accountTaken": "Имя пользователя или email уже заняты.", "error.emailRequired": "Введите email.", "error.passwordMismatch": "Пароли не совпадают.", + "error.invalidPublicURL": "Укажите корректный абсолютный public URL с http или https.", + "error.rateLimited": "Слишком много попыток. Подождите и повторите запрос.", + "error.notFound": "Страница не найдена.", + "error.deviceMustBeRevoked": "Удалять можно только предварительно отозванное устройство.", + "home.unavailableTitle": "Сервер временно недоступен", + "home.unavailableMessage": "Сервер ещё не готов обслуживать запросы. Повторите попытку позже.", "error.invalidCredentials": "Неверное имя пользователя или пароль.", "error.internal": "Внутренняя ошибка. Повторите попытку позже.", "admin.eyebrow": "Администрирование сервера", @@ -86,7 +92,7 @@ var _translations = map[string]map[string]string{ "admin.access": "Доступ", "admin.activeDevices": "Активные устройства", "admin.operations": "Операции", - "admin.vaults": "Vault", + "admin.vaults": "Хранилища", "admin.storage": "Хранилище", "admin.audit": "Аудит", "admin.settings": "Настройки", @@ -122,11 +128,17 @@ var _translations = map[string]map[string]string{ "admin.smtpConfigured": "SMTP настраивается отдельно и пароль никогда не возвращается в форму.", "user.account": "Моя учётная запись", "user.devices": "Подключённые устройства", + "user.emailConfirmed": "Email подтверждён", + "user.emailUnconfirmed": "Email не подтверждён", + "user.filterDevices": "Поиск устройств", + "user.connectInstruction": "Подключите новое устройство через приложение Verstak: откройте настройки синхронизации и выполните pairing с этим сервером.", + "user.deviceRevoked": "Устройство отозвано.", "user.noDevices": "Устройств пока нет", "device.name": "Устройство", - "device.vault": "Vault", + "device.vault": "Хранилище", "device.version": "Версия клиента", "device.lastSeen": "Последняя активность", + "device.created": "Подключено", "device.status": "Статус", "device.active": "Активно", "device.revoked": "Отозвано", @@ -227,10 +239,71 @@ var _translations = map[string]map[string]string{ "admin.smtpPassed": "✓ Тест пройден", "admin.smtpRequired": "Укажите SMTP-сервер, порт и отправителя.", "admin.smtpTestFailed": "Не удалось проверить настройки SMTP. Повторите попытку.", + "admin.settingsSaved": "Настройки сервера сохранены.", + "admin.smtpSaved": "Настройки SMTP сохранены.", + "admin.smtpStartTLS": "STARTTLS", + "admin.smtpTLS": "TLS", + "admin.warningReadiness": "Один или несколько компонентов сервера не готовы к работе.", + "admin.warningSMTP": "SMTP не настроен: письма подтверждения и сброса пароля не будут отправляться.", + "admin.warningOperations": "Журнал операций быстро растёт. Не удаляйте его без checkpoint-механизма.", + "admin.ops24h": "за 24 часа", + "admin.sort": "Сортировка", + "admin.created": "Создан", + "admin.ip": "IP", + "admin.message": "Сообщение", + "admin.severity": "Важность", + "admin.info": "Информация", + "admin.warningLevel": "Предупреждение", + "admin.errorLevel": "Ошибка", + "admin.publicURL": "Публичный URL", + "admin.saveSettings": "Сохранить настройки", + "admin.trustedProxies": "Доверенные reverse proxy", + "admin.network": "Сеть", + "admin.limits": "Лимиты и retention", + "admin.readOnlyConfig": "Лимиты задаются конфигурацией сервера и доступны здесь только для просмотра.", + "admin.maxJSONBody": "Максимальный JSON body", + "admin.maxPushOperations": "Операций в push", + "admin.maxPullPage": "Операций в pull page", + "admin.maxBlobBytes": "Максимальный размер blob", + "admin.maxVaultBlobBytes": "Квота blob vault", + "admin.maxUserBlobBytes": "Квота blob пользователя", + "admin.sequence": "Последняя sequence", + "admin.refresh": "Обновить", + "admin.copyDiagnostics": "Скопировать диагностику", + "admin.copied": "Скопировано", + "admin.blobReferences": "Ссылки на blobs", + "admin.orphanBlobs": "Blob без ссылок", + "admin.tempUploads": "Временные загрузки", + "admin.expiredSessions": "Истёкшие сессии", + "admin.expiredTokens": "Истёкшие email-токены", + "admin.lastCleanup": "Последняя очистка", + "admin.cleanupDone": "Безопасная очистка завершена.", + "audit.other": "Другое событие", + "audit.deviceAuthFailed": "Ошибка авторизации устройства", + "audit.devicePaired": "Устройство подключено", + "audit.deviceRevoked": "Устройство отозвано", + "audit.deviceDeleted": "Отозванное устройство удалено", + "audit.rateLimited": "Сработало ограничение попыток", + "audit.retentionCleanup": "Выполнена безопасная очистка", + "audit.smtpSettingsUpdated": "Настройки SMTP изменены", + "audit.smtpTestFailed": "Проверка SMTP не удалась", + "audit.smtpTestPassed": "Проверка SMTP прошла", + "audit.userBlockChanged": "Статус блокировки пользователя изменён", + "audit.userConfirmed": "Email пользователя подтверждён", + "audit.userCreated": "Пользователь создан", + "audit.userDeleted": "Пользователь удалён", + "audit.userPasswordReset": "Пароль пользователя сброшен", + "audit.userUpdated": "Пользователь изменён", + "audit.webSettingsUpdated": "Общие настройки изменены", + "status.ok": "Работает", + "status.degraded": "Требует внимания", + "status.available": "Доступно", + "status.unavailable": "Недоступно", "admin.revokeConfirm": "Вы уверены?", "common.loading": "Загрузка...", "common.ok": "OK", "common.error": "Ошибка", + "common.warning": "Предупреждения", "admin.filterPlaceholder": "Поиск...", "admin.email": "Email", "admin.actions": "Действия", @@ -249,8 +322,14 @@ var _translations = map[string]map[string]string{ "admin.noUsers": "Нет пользователей", "admin.resetPasswordConfirm": "Сбросить пароль", "admin.resetPasswordMessage": "Новый пароль: ", + "admin.generatePassword": "Сгенерировать одноразовый пароль", + "admin.oneTimePasswordNotice": "Сохраните этот пароль сейчас: он будет показан только один раз.", + "admin.oneTimePasswordHint": "После ухода со страницы получить пароль повторно нельзя. Пользователь должен сменить его после входа.", "admin.resetBtn": "Сбросить", "admin.deleteUser": "Удалить", + "admin.deleteDevice": "Удалить устройство", + "admin.confirmUser": "Подтвердить email", + "admin.deleteUserConfirm": "Удалить пользователя и все связанные устройства, vault-связи, blobs и операции? Это действие нельзя отменить.", "admin.deleteUserMessage": "Удалить пользователя %s?", "admin.deleteBtn": "Удалить", "admin.unblockUserTitle": "Разблокировать", @@ -320,6 +399,12 @@ var _translations = map[string]map[string]string{ "error.accountTaken": "Username or email is already in use.", "error.emailRequired": "Enter an email address.", "error.passwordMismatch": "Passwords do not match.", + "error.invalidPublicURL": "Enter a valid absolute public URL using http or https.", + "error.rateLimited": "Too many attempts. Wait and try again.", + "error.notFound": "Page not found.", + "error.deviceMustBeRevoked": "Only a revoked device can be deleted.", + "home.unavailableTitle": "Server is temporarily unavailable", + "home.unavailableMessage": "The server is not ready to serve requests yet. Try again later.", "error.invalidCredentials": "Invalid username or password.", "error.internal": "Internal error. Please try again later.", "admin.eyebrow": "Server administration", @@ -365,11 +450,17 @@ var _translations = map[string]map[string]string{ "admin.smtpConfigured": "SMTP is configured separately and its password is never returned to a form.", "user.account": "My account", "user.devices": "Connected devices", + "user.emailConfirmed": "Email confirmed", + "user.emailUnconfirmed": "Email not confirmed", + "user.filterDevices": "Search devices", + "user.connectInstruction": "Connect another device in Verstak: open sync settings and pair it with this server.", + "user.deviceRevoked": "Device revoked.", "user.noDevices": "No devices yet", "device.name": "Device", "device.vault": "Vault", "device.version": "Client version", "device.lastSeen": "Last activity", + "device.created": "Connected", "device.status": "Status", "device.active": "Active", "device.revoked": "Revoked", @@ -470,10 +561,71 @@ var _translations = map[string]map[string]string{ "admin.smtpPassed": "✓ Test passed", "admin.smtpRequired": "Enter the SMTP server, port, and sender.", "admin.smtpTestFailed": "Could not test the SMTP settings. Please try again.", + "admin.settingsSaved": "Server settings saved.", + "admin.smtpSaved": "SMTP settings saved.", + "admin.smtpStartTLS": "STARTTLS", + "admin.smtpTLS": "TLS", + "admin.warningReadiness": "One or more server components are not ready.", + "admin.warningSMTP": "SMTP is not configured: confirmation and password-reset emails will not be sent.", + "admin.warningOperations": "The operation log is growing quickly. Do not prune it without a checkpoint mechanism.", + "admin.ops24h": "in the last 24 hours", + "admin.sort": "Sort", + "admin.created": "Created", + "admin.ip": "IP", + "admin.message": "Message", + "admin.severity": "Severity", + "admin.info": "Info", + "admin.warningLevel": "Warning", + "admin.errorLevel": "Error", + "admin.publicURL": "Public URL", + "admin.saveSettings": "Save settings", + "admin.trustedProxies": "Trusted reverse proxies", + "admin.network": "Network", + "admin.limits": "Limits and retention", + "admin.readOnlyConfig": "Limits are configured on the server and are read-only here.", + "admin.maxJSONBody": "Maximum JSON body", + "admin.maxPushOperations": "Operations per push", + "admin.maxPullPage": "Operations per pull page", + "admin.maxBlobBytes": "Maximum blob size", + "admin.maxVaultBlobBytes": "Vault blob quota", + "admin.maxUserBlobBytes": "User blob quota", + "admin.sequence": "Latest sequence", + "admin.refresh": "Refresh", + "admin.copyDiagnostics": "Copy diagnostics", + "admin.copied": "Copied", + "admin.blobReferences": "Blob references", + "admin.orphanBlobs": "Unreferenced blobs", + "admin.tempUploads": "Temporary uploads", + "admin.expiredSessions": "Expired sessions", + "admin.expiredTokens": "Expired email tokens", + "admin.lastCleanup": "Last cleanup", + "admin.cleanupDone": "Safe cleanup completed.", + "audit.other": "Other event", + "audit.deviceAuthFailed": "Device authentication failed", + "audit.devicePaired": "Device paired", + "audit.deviceRevoked": "Device revoked", + "audit.deviceDeleted": "Revoked device deleted", + "audit.rateLimited": "Rate limit triggered", + "audit.retentionCleanup": "Safe cleanup completed", + "audit.smtpSettingsUpdated": "SMTP settings updated", + "audit.smtpTestFailed": "SMTP test failed", + "audit.smtpTestPassed": "SMTP test passed", + "audit.userBlockChanged": "User block status changed", + "audit.userConfirmed": "User email confirmed", + "audit.userCreated": "User created", + "audit.userDeleted": "User deleted", + "audit.userPasswordReset": "User password reset", + "audit.userUpdated": "User updated", + "audit.webSettingsUpdated": "General settings updated", + "status.ok": "Operational", + "status.degraded": "Needs attention", + "status.available": "Available", + "status.unavailable": "Unavailable", "admin.revokeConfirm": "Are you sure?", "common.loading": "Loading...", "common.ok": "OK", "common.error": "Error", + "common.warning": "Warnings", "admin.filterPlaceholder": "Search...", "admin.email": "Email", "admin.actions": "Actions", @@ -492,8 +644,14 @@ var _translations = map[string]map[string]string{ "admin.noUsers": "No users", "admin.resetPasswordConfirm": "Reset Password", "admin.resetPasswordMessage": "New password: ", + "admin.generatePassword": "Generate one-time password", + "admin.oneTimePasswordNotice": "Save this password now: it is shown only once.", + "admin.oneTimePasswordHint": "It cannot be retrieved after leaving this page. The user should change it after signing in.", "admin.resetBtn": "Reset", "admin.deleteUser": "Delete", + "admin.deleteDevice": "Delete device", + "admin.confirmUser": "Confirm email", + "admin.deleteUserConfirm": "Delete this user and all related devices, vault associations, blobs, and operations? This cannot be undone.", "admin.deleteUserMessage": "Delete user %s?", "admin.deleteBtn": "Delete", "admin.unblockUserTitle": "Unblock", diff --git a/internal/server/rate.go b/internal/server/rate.go index c41b7f8..b8702fe 100644 --- a/internal/server/rate.go +++ b/internal/server/rate.go @@ -18,12 +18,12 @@ var ratePolicies = map[string]RatePolicy{ "admin-reset": {Limit: 8, Window: time.Hour}, } -// allowRate applies an IP limit and, where a login/account is supplied, an -// additional bounded account bucket. It never logs submitted credentials. -func (s *Server) allowRate(w http.ResponseWriter, r *http.Request, action, account string) bool { +// rateRetryAfter applies an IP limit and, where a login/account is supplied, +// an additional bounded account bucket. It never logs submitted credentials. +func (s *Server) rateRetryAfter(r *http.Request, action, account string) (int, bool) { policy, ok := ratePolicies[action] if !ok { - return true + return 0, true } ip := s.clientIP(r) s.limiter.Cleanup(2 * policy.Window) @@ -40,13 +40,34 @@ func (s *Server) allowRate(w http.ResponseWriter, r *http.Request, action, accou if seconds < 1 { seconds = 1 } - w.Header().Set("Retry-After", strconvItoa(seconds)) s.auditLog("rate_limit_exceeded", "", "", ip, "rate limit: "+action) - jsonErr(w, http.StatusTooManyRequests, "too many attempts") - return false + return seconds, false } } - return true + return 0, true +} + +// allowRate writes API-compatible JSON for transport endpoints. +func (s *Server) allowRate(w http.ResponseWriter, r *http.Request, action, account string) bool { + retryAfter, allowed := s.rateRetryAfter(r, action, account) + if allowed { + return true + } + w.Header().Set("Retry-After", strconvItoa(retryAfter)) + jsonErr(w, http.StatusTooManyRequests, "too many attempts") + return false +} + +// allowWebRate is the HTML counterpart of allowRate. Browser forms receive a +// localized error page instead of a machine-readable API error. +func (s *Server) allowWebRate(w http.ResponseWriter, r *http.Request, action, account, back string) bool { + retryAfter, allowed := s.rateRetryAfter(r, action, account) + if allowed { + return true + } + w.Header().Set("Retry-After", strconvItoa(retryAfter)) + s.renderWebError(w, r, http.StatusTooManyRequests, "error.rateLimited", back) + return false } func strconvItoa(value int) string { diff --git a/internal/server/routes.go b/internal/server/routes.go index e8dd06c..0fb962f 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -37,6 +37,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/admin", s.handleAdminRoot) s.mux.HandleFunc("/admin/logout", s.handleAdminWebLogout) s.mux.HandleFunc("/admin/action", s.handleAdminWebAction) + s.mux.HandleFunc("/admin/password-result", s.handleAdminPasswordResult) s.mux.HandleFunc("/admin/dashboard", s.handleAdminWeb) s.mux.HandleFunc("/admin/users", s.handleAdminWeb) s.mux.HandleFunc("/admin/create-user", s.handleAdminCreateUserWeb) diff --git a/internal/server/server.go b/internal/server/server.go index c6c15d0..92e956f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -8,20 +8,23 @@ import ( "os" "path/filepath" "strings" + "sync" "time" _ "github.com/mattn/go-sqlite3" ) type Server struct { - db *sql.DB - dbPath string - cfg *Config - blobsDir string - mux *http.ServeMux - limiter *rateLimiter - web *webRenderer - startedAt time.Time + db *sql.DB + dbPath string + cfg *Config + blobsDir string + mux *http.ServeMux + limiter *rateLimiter + web *webRenderer + startedAt time.Time + secretMu sync.Mutex + webSecrets map[string]oneTimeWebSecret } // Version and BuildCommit are assigned through -ldflags during release builds. @@ -89,13 +92,14 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) { return nil, fmt.Errorf("web templates: %w", err) } s := &Server{ - db: db, - dbPath: dbPath, - cfg: cfg, - blobsDir: blobsDir, - limiter: newRateLimiter(nil), - web: web, - startedAt: time.Now().UTC(), + db: db, + dbPath: dbPath, + cfg: cfg, + blobsDir: blobsDir, + limiter: newRateLimiter(nil), + web: web, + startedAt: time.Now().UTC(), + webSecrets: make(map[string]oneTimeWebSecret), } s.mux = http.NewServeMux() return s, nil diff --git a/internal/server/server_test.go b/internal/server/server_test.go index c23ffab..e6ab7db 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -640,12 +640,14 @@ func pairSyncDevice(t *testing.T, serverURL, username, password, vaultID string) func postWebReset(t *testing.T, s *Server, token, password string) *httptest.ResponseRecorder { t.Helper() form := url.Values{ - "token": {token}, - "password": {password}, - "confirm": {password}, + "token": {token}, + "password": {password}, + "confirm": {password}, + "locale_csrf": {"test-public-csrf"}, } request := httptest.NewRequest(http.MethodPost, "/reset", strings.NewReader(form.Encode())) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "test-public-csrf"}) response := httptest.NewRecorder() s.mux.ServeHTTP(response, request) return response diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 74bd2f4..6427625 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -26,17 +26,22 @@ body { min-width:320px; margin:0; background:var(--bg); color:var(--text); font: :focus-visible { outline:3px solid var(--focus); outline-offset:3px; } .stack { display:grid; gap:1rem; } .stack label { display:grid; gap:.35rem; font-weight:650; } -.stack input,.inline-form input { width:100%; min-height:42px; padding:.55rem .65rem; border:1px solid var(--line); border-radius:8px; background:#0d151a; color:var(--text); font:inherit; } +.stack input:not([type="checkbox"]),.stack select,.inline-form input { width:100%; min-height:42px; padding:.55rem .65rem; border:1px solid var(--line); border-radius:8px; background:#0d151a; color:var(--text); font:inherit; } +.check-field { display:flex !important; align-items:center; gap:.55rem; }.check-field input[type="checkbox"] { width:1.1rem; height:1.1rem; margin:0; accent-color:var(--accent); } .flash { padding:.75rem; border-radius:8px; } .flash.error { border:1px solid #6e343b; background:#3a2025; color:#ffd9d9; } +.flash.success { border:1px solid #286052; background:#173d36; color:#a5f1df; } +.one-time-secret { display:block; overflow-wrap:anywhere; padding:.8rem; border:1px solid var(--accent); border-radius:.55rem; background:#0c171d; color:var(--accent); font-size:1.1rem; } +.warning { border-color:#6b5b28; background:#302a16; color:#ffedb0; } .dashboard-head { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; margin-bottom:1.25rem; } .table-card { padding:1.25rem; } .table-scroll { overflow-x:auto; } -table { width:100%; border-collapse:collapse; } +table { width:100%; min-width:720px; border-collapse:collapse; } th,td { padding:.75rem; border-bottom:1px solid var(--line); text-align:left; vertical-align:middle; } th { color:var(--muted); font-size:.8rem; letter-spacing:.06em; text-transform:uppercase; } .badge { display:inline-flex; padding:.2rem .55rem; border-radius:999px; font-size:.8rem; font-weight:700; } .badge.ok { background:#173d36; color:#91ecd9; }.badge.danger { background:#4c2529; color:#ffd9d9; } +.badge.warning { background:#4b421d; color:#ffed9a; } .inline-form { display:flex; min-width:250px; gap:.4rem; }.inline-form input { min-width:120px; } .link-button { padding:0; border:0; background:transparent; color:var(--muted); font:inherit; cursor:pointer; } .admin-shell { display:grid; grid-template-columns:220px minmax(0,1fr); gap:1.5rem; } @@ -49,4 +54,5 @@ details { margin-top:.6rem; } summary { cursor:pointer; color:var(--accent); }.c .list-filter,.pager { display:flex; flex-wrap:wrap; align-items:end; gap:.65rem; margin:0 0 1rem; }.list-filter label { display:grid; gap:.3rem; color:var(--muted); }.list-filter input,.list-filter select { min-height:38px; padding:.4rem .55rem; border:1px solid var(--line); border-radius:7px; background:#0d151a; color:var(--text); }.pager { justify-content:flex-end; align-items:center; } .mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }.muted,.empty { color:var(--muted); }.empty { padding:2rem; text-align:center; } .sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } +.confirm-dialog { width:min(calc(100% - 2rem),420px); margin:auto; padding:1.25rem; border:1px solid var(--line); border-radius:14px; background:var(--surface); color:var(--text); box-shadow:0 24px 64px #0009; }.confirm-dialog::backdrop { background:#05080bb8; }.confirm-dialog h2,.confirm-dialog p { margin:0; } @media (max-width:760px) { .site-header,.site-footer,.dashboard-head,.section-heading { align-items:flex-start; flex-direction:column; }.locale-form { margin-left:0; }.page-shell { width:min(100% - 1.25rem,1100px); padding:2rem 0; }.hero { padding:1.5rem; }.inline-form { min-width:0; flex-wrap:wrap; }.admin-shell { grid-template-columns:1fr; }.admin-nav { grid-template-columns:repeat(2,minmax(0,1fr)); }.admin-nav .eyebrow { grid-column:1 / -1; } } diff --git a/internal/server/web/static/app.js b/internal/server/web/static/app.js index a0bb498..460e5ab 100644 --- a/internal/server/web/static/app.js +++ b/internal/server/web/static/app.js @@ -1,7 +1,38 @@ document.addEventListener("change", function (event) { if (event.target.matches("[data-auto-submit]")) event.target.form.requestSubmit(); }); + +const confirmationDialog = document.getElementById("confirm-dialog"); +let confirmationForm = null; +let confirmationButton = null; document.addEventListener("click", function (event) { const button = event.target.closest("[data-confirm]"); - if (button && !window.confirm(button.dataset.confirm)) event.preventDefault(); + if (!button || !button.form || !confirmationDialog) return; + event.preventDefault(); + confirmationForm = button.form; + confirmationButton = button; + document.getElementById("confirm-dialog-message").textContent = button.dataset.confirm; + confirmationDialog.showModal(); +}); +if (confirmationDialog) { + confirmationDialog.addEventListener("close", function () { + if (confirmationDialog.returnValue === "confirm" && confirmationForm) { + confirmationForm.requestSubmit(confirmationButton); + } + confirmationForm = null; + confirmationButton = null; + }); +} + +document.addEventListener("click", async function (event) { + const button = event.target.closest("[data-copy-diagnostics]"); + if (!button || !navigator.clipboard) return; + try { + const response = await fetch(button.dataset.copyDiagnostics, { credentials: "same-origin" }); + if (!response.ok) return; + await navigator.clipboard.writeText(await response.text()); + button.textContent = button.dataset.copiedLabel; + } catch (_) { + // The downloadable JSON link remains available when clipboard access is unavailable. + } }); diff --git a/internal/server/web/templates/admin.html b/internal/server/web/templates/admin.html deleted file mode 100644 index 7bef216..0000000 --- a/internal/server/web/templates/admin.html +++ /dev/null @@ -1,29 +0,0 @@ -{{define "admin"}}{{template "layout" .}}{{end}} -{{define "content"}} -
- -
- {{if or (eq .AdminPage "users") (or (eq .AdminPage "devices") (eq .AdminPage "audit"))}}
{{end}} - {{if eq .AdminPage "dashboard"}}

{{t .Locale "admin.overview"}}

{{t .Locale "admin.dashboard"}}

{{.Stats.Users}}{{t .Locale "admin.users"}}
{{.Stats.ActiveDevices}}{{t .Locale "admin.activeDevices"}}
{{.Stats.Vaults}}{{t .Locale "admin.vaults"}}
{{.Stats.Operations}}{{t .Locale "admin.operations"}}

{{t .Locale "admin.serviceHealth"}}

{{t .Locale "admin.status"}}
{{.Health.Status}}
{{t .Locale "admin.version"}}
{{.Health.Version}} {{.Health.BuildCommit}}
{{t .Locale "admin.lastActivity"}}
{{.Stats.LastSyncAt}}
- {{else if eq .AdminPage "users"}}

{{t .Locale "admin.access"}}

{{t .Locale "admin.users"}}

{{t .Locale "admin.createUser"}}
{{range .AdminUsers}}{{else}}{{end}}
{{t .Locale "field.username"}}{{t .Locale "field.email"}}{{t .Locale "admin.devices"}}{{t .Locale "device.status"}}{{t .Locale "common.actions"}}
{{.Username}}{{.Email}}{{.Devices}}{{if .Blocked}}{{t $.Locale "admin.blocked"}}{{else if .Confirmed}}{{t $.Locale "device.active"}}{{else}}{{t $.Locale "admin.unconfirmed"}}{{end}}
{{t $.Locale "admin.manage"}}
{{t .Locale "admin.noUsers"}}
- {{else if eq .AdminPage "devices"}}

{{t .Locale "admin.access"}}

{{t .Locale "admin.devices"}}

{{range .AdminDevices}}{{else}}{{end}}
{{t .Locale "device.name"}}{{t .Locale "admin.user"}}{{t .Locale "device.vault"}}{{t .Locale "device.lastSeen"}}{{t .Locale "device.status"}}{{t .Locale "common.actions"}}
{{.Name}}{{.User}}{{short .Vault 16}}{{.LastSeen}}{{if .Revoked}}{{t $.Locale "device.revoked"}}{{else}}{{t $.Locale "device.active"}}{{end}}{{if not .Revoked}}
{{end}}
{{t .Locale "admin.noDevices"}}
- {{else if eq .AdminPage "vaults"}}

{{t .Locale "admin.storage"}}

{{t .Locale "admin.vaults"}}

{{range .Vaults}}{{else}}{{end}}
{{t .Locale "admin.user"}}{{t .Locale "device.vault"}}{{t .Locale "admin.devices"}}{{t .Locale "admin.operations"}}{{t .Locale "admin.lastActivity"}}
{{.User}}{{short .Vault 24}}{{.Devices}}{{.Operations}}{{.LastActivity}}
{{t .Locale "admin.noVaults"}}
- {{else if eq .AdminPage "storage"}}

{{t .Locale "admin.storage"}}

{{t .Locale "admin.storage"}}

{{.Stats.DatabaseBytes}}{{t .Locale "admin.databaseBytes"}}
{{.Stats.BlobBytes}}{{t .Locale "admin.blobBytes"}}
{{.Stats.Operations}}{{t .Locale "admin.operations"}}

{{t .Locale "admin.retentionNote"}}

- {{else if eq .AdminPage "audit"}}

{{t .Locale "admin.diagnostics"}}

{{t .Locale "admin.audit"}}

{{range .Audit}}{{else}}{{end}}
{{t .Locale "admin.event"}}{{t .Locale "admin.user"}}{{t .Locale "device.name"}}{{t .Locale "admin.time"}}
{{.Event}}{{.User}}{{.Device}}{{.At}}
{{t .Locale "admin.noAudit"}}
- {{else if eq .AdminPage "settings"}}

{{t .Locale "admin.settings"}}

{{t .Locale "admin.smtpTitle"}}

- {{else if eq .AdminPage "diagnostics"}}

{{t .Locale "admin.diagnostics"}}

{{t .Locale "admin.diagnostics"}}

{{t .Locale "admin.status"}}
{{.Health.Status}}
{{t .Locale "admin.database"}}
{{.Health.DatabaseReachable}}
{{t .Locale "admin.blobStorage"}}
{{.Health.BlobStorageWritable}}
{{t .Locale "admin.schema"}}
{{.Health.SchemaVersion}}
{{t .Locale "admin.serverTime"}}
{{.Health.ServerTime}}
{{end}} - {{if or (eq .AdminPage "users") (or (eq .AdminPage "devices") (eq .AdminPage "audit"))}}{{end}} -
-
-{{end}} diff --git a/internal/server/web/templates/admin_audit.html b/internal/server/web/templates/admin_audit.html new file mode 100644 index 0000000..dd28e6b --- /dev/null +++ b/internal/server/web/templates/admin_audit.html @@ -0,0 +1,9 @@ +{{define "admin_audit"}}{{template "layout" .}}{{end}} +{{define "content"}} +
{{template "admin_nav" .}}
+

{{t .Locale "admin.diagnostics"}}

{{t .Locale "admin.audit"}}

+
+
{{range .Audit}}{{else}}{{end}}
{{t .Locale "admin.time"}}{{t .Locale "admin.event"}}{{t .Locale "admin.user"}}{{t .Locale "device.name"}}{{t .Locale "admin.ip"}}{{t .Locale "admin.severity"}}{{t .Locale "admin.message"}}
{{webtime $.Locale .At}}{{auditlabel $.Locale .Event}}{{.User}}{{.Device}}{{.IP}}{{if eq .Severity "error"}}{{t $.Locale "admin.errorLevel"}}{{else if eq .Severity "warning"}}{{t $.Locale "admin.warningLevel"}}{{else}}{{t $.Locale "admin.info"}}{{end}}{{.Message}}
{{t .Locale "admin.noAudit"}}
+ +
+{{end}} diff --git a/internal/server/web/templates/admin_dashboard.html b/internal/server/web/templates/admin_dashboard.html new file mode 100644 index 0000000..6beb3ed --- /dev/null +++ b/internal/server/web/templates/admin_dashboard.html @@ -0,0 +1,10 @@ +{{define "admin_dashboard"}}{{template "layout" .}}{{end}} +{{define "content"}} +
{{template "admin_nav" .}}
+

{{t .Locale "admin.overview"}}

{{t .Locale "admin.dashboard"}}

+
{{.Stats.Users}}{{t .Locale "admin.users"}} · {{.Stats.ActiveUsers}} {{t .Locale "device.active"}}
{{.Stats.ActiveDevices}}{{t .Locale "admin.activeDevices"}} · {{.Stats.RevokedDevices}} {{t .Locale "device.revoked"}}
{{.Stats.Vaults}}{{t .Locale "admin.vaults"}}
{{.Stats.Operations}}{{t .Locale "admin.operations"}} · {{.Stats.Operations24h}} {{t .Locale "admin.ops24h"}}
{{webbytes .Stats.DatabaseBytes}}{{t .Locale "admin.databaseBytes"}}
{{webbytes .Stats.BlobBytes}}{{.Stats.Blobs}} {{t .Locale "admin.blobBytes"}}
+ {{if .Warnings}}

{{t .Locale "common.warning"}}

{{range .Warnings}}

{{t $.Locale .}}

{{end}}
{{end}} +

{{t .Locale "admin.serviceHealth"}}

{{t .Locale "admin.status"}}
{{statuslabel .Locale .Health.Status}}
{{t .Locale "admin.version"}}
{{.Health.Version}} {{.Health.BuildCommit}}
{{t .Locale "admin.lastActivity"}}
{{webtime .Locale .Stats.LastSyncAt}}
{{t .Locale "admin.serverTime"}}
{{webtime .Locale .Health.ServerTime}}
+

{{t .Locale "admin.audit"}}

{{range .Audit}}{{else}}{{end}}
{{t .Locale "admin.event"}}{{t .Locale "admin.time"}}
{{auditlabel $.Locale .Event}}{{webtime $.Locale .At}}
{{t .Locale "admin.noAudit"}}
+
+{{end}} diff --git a/internal/server/web/templates/admin_devices.html b/internal/server/web/templates/admin_devices.html new file mode 100644 index 0000000..2d8aaa7 --- /dev/null +++ b/internal/server/web/templates/admin_devices.html @@ -0,0 +1,9 @@ +{{define "admin_devices"}}{{template "layout" .}}{{end}} +{{define "content"}} +
{{template "admin_nav" .}}
+

{{t .Locale "admin.access"}}

{{t .Locale "admin.devices"}}

+
+
{{range .AdminDevices}}{{else}}{{end}}
{{t .Locale "device.name"}}{{t .Locale "admin.user"}}{{t .Locale "device.vault"}}{{t .Locale "device.version"}}{{t .Locale "admin.ip"}}{{t .Locale "admin.created"}}{{t .Locale "device.lastSeen"}}{{t .Locale "device.status"}}{{t .Locale "common.actions"}}
{{.Name}}{{if .TokenHint}}{{.TokenHint}}{{end}}{{.User}}{{short .Vault 16}}{{.Version}}{{.LastIP}}{{webtime $.Locale .CreatedAt}}{{webtime $.Locale .LastSeen}}{{if .Revoked}}{{t $.Locale "device.revoked"}}{{else}}{{t $.Locale "device.active"}}{{end}}{{if not .Revoked}}
{{else}}
{{end}}
{{t .Locale "admin.noDevices"}}
+ +
+{{end}} diff --git a/internal/server/web/templates/admin_diagnostics.html b/internal/server/web/templates/admin_diagnostics.html new file mode 100644 index 0000000..0244d86 --- /dev/null +++ b/internal/server/web/templates/admin_diagnostics.html @@ -0,0 +1,7 @@ +{{define "admin_diagnostics"}}{{template "layout" .}}{{end}} +{{define "content"}} +
{{template "admin_nav" .}}
+

{{t .Locale "admin.diagnostics"}}

{{t .Locale "admin.diagnostics"}}

+
{{t .Locale "admin.status"}}
{{statuslabel .Locale .Health.Status}}
{{t .Locale "admin.database"}}
{{boollabel .Locale .Health.DatabaseReachable}}
{{t .Locale "admin.blobStorage"}}
{{boollabel .Locale .Health.BlobStorageWritable}}
{{t .Locale "admin.schema"}}
{{.Health.SchemaVersion}}
{{t .Locale "admin.serverTime"}}
{{webtime .Locale .Health.ServerTime}}
+
+{{end}} diff --git a/internal/server/web/templates/admin_login.html b/internal/server/web/templates/admin_login.html index 7229d72..583b873 100644 --- a/internal/server/web/templates/admin_login.html +++ b/internal/server/web/templates/admin_login.html @@ -1,2 +1,2 @@ {{define "admin_login"}}{{template "layout" .}}{{end}} -{{define "content"}}

{{t .Locale "admin.eyebrow"}}

{{t .Locale "admin.loginTitle"}}

{{if .Flash}}{{end}}
{{end}} +{{define "content"}}

{{t .Locale "admin.eyebrow"}}

{{t .Locale "admin.loginTitle"}}

{{if .Flash}}{{end}}
{{end}} diff --git a/internal/server/web/templates/admin_nav.html b/internal/server/web/templates/admin_nav.html new file mode 100644 index 0000000..5e977f5 --- /dev/null +++ b/internal/server/web/templates/admin_nav.html @@ -0,0 +1,14 @@ +{{define "admin_nav"}} + +{{end}} diff --git a/internal/server/web/templates/admin_password_result.html b/internal/server/web/templates/admin_password_result.html new file mode 100644 index 0000000..d41b7f4 --- /dev/null +++ b/internal/server/web/templates/admin_password_result.html @@ -0,0 +1,8 @@ +{{define "admin_password_result"}}{{template "layout" .}}{{end}} +{{define "content"}} +
{{template "admin_nav" .}}
+

{{t .Locale "admin.resultTitle"}}

{{t .Locale "admin.resetPassword"}}

+

{{t .Locale "admin.oneTimePasswordNotice"}}

{{.OneTimeSecret}}

{{t .Locale "admin.oneTimePasswordHint"}}

+ {{t .Locale "admin.users"}} +
+{{end}} diff --git a/internal/server/web/templates/admin_settings.html b/internal/server/web/templates/admin_settings.html index 976317e..587db84 100644 --- a/internal/server/web/templates/admin_settings.html +++ b/internal/server/web/templates/admin_settings.html @@ -1,2 +1,47 @@ {{define "admin_settings"}}{{template "layout" .}}{{end}} -{{define "content"}}

{{t .Locale "admin.settings"}}

{{t .Locale "admin.settings"}}

{{t .Locale "admin.general"}}

{{t .Locale "admin.smtpTitle"}}

{{t .Locale "admin.smtpConfigured"}}

{{t .Locale "common.back"}}
{{end}} +{{define "content"}} +
+ {{template "admin_nav" .}} +
+

{{t .Locale "admin.settings"}}

+

{{t .Locale "admin.settings"}}

+ {{if .Flash}}

{{t .Locale .Flash}}

{{end}} +
+

{{t .Locale "admin.general"}}

+
+ + + + + + + +
+
+

{{t .Locale "admin.network"}}

{{t .Locale "admin.trustedProxies"}}
{{if .TrustedProxies}}{{.TrustedProxies}}{{else}}—{{end}}
+
+

{{t .Locale "admin.smtpTitle"}}

+

{{t .Locale "admin.smtpConfigured"}}

+
+ + + + + + + + + + +
+
+ + + + +
+
+

{{t .Locale "admin.limits"}}

{{t .Locale "admin.readOnlyConfig"}}

{{t .Locale "admin.maxJSONBody"}}
{{webbytes .Limits.MaxJSONBody}}
{{t .Locale "admin.maxPushOperations"}}
{{.Limits.MaxPushOperations}}
{{t .Locale "admin.maxPullPage"}}
{{.Limits.MaxPullPage}}
{{t .Locale "admin.maxBlobBytes"}}
{{webbytes .Limits.MaxBlobBytes}}
{{t .Locale "admin.maxVaultBlobBytes"}}
{{webbytes .Limits.MaxVaultBlobBytes}}
{{t .Locale "admin.maxUserBlobBytes"}}
{{webbytes .Limits.MaxUserBlobBytes}}
+
+
+{{end}} diff --git a/internal/server/web/templates/admin_storage.html b/internal/server/web/templates/admin_storage.html new file mode 100644 index 0000000..7e05d37 --- /dev/null +++ b/internal/server/web/templates/admin_storage.html @@ -0,0 +1,10 @@ +{{define "admin_storage"}}{{template "layout" .}}{{end}} +{{define "content"}} +
{{template "admin_nav" .}}
+

{{t .Locale "admin.storage"}}

{{t .Locale "admin.storage"}}

+ {{if .Flash}}

{{t .Locale .Flash}}

{{end}} +
{{webbytes .Stats.DatabaseBytes}}{{t .Locale "admin.databaseBytes"}}
{{webbytes .Stats.BlobBytes}}{{.Stats.Blobs}} {{t .Locale "admin.blobBytes"}}
{{.Stats.Operations}}{{t .Locale "admin.operations"}}
+
{{t .Locale "admin.blobReferences"}}
{{.Stats.BlobReferences}}
{{t .Locale "admin.orphanBlobs"}}
{{.Stats.OrphanBlobs}}
{{t .Locale "admin.tempUploads"}}
{{.Stats.TempUploads}}
{{t .Locale "admin.expiredSessions"}}
{{.Stats.ExpiredSessions}}
{{t .Locale "admin.expiredTokens"}}
{{.Stats.ExpiredTokens}}
{{t .Locale "admin.lastCleanup"}}
{{webtime .Locale .Stats.LastCleanupAt}}
+

{{t .Locale "admin.retentionNote"}}

+
+{{end}} diff --git a/internal/server/web/templates/admin_users.html b/internal/server/web/templates/admin_users.html new file mode 100644 index 0000000..9171bfb --- /dev/null +++ b/internal/server/web/templates/admin_users.html @@ -0,0 +1,9 @@ +{{define "admin_users"}}{{template "layout" .}}{{end}} +{{define "content"}} +
{{template "admin_nav" .}}
+

{{t .Locale "admin.access"}}

{{t .Locale "admin.users"}}

{{t .Locale "admin.createUser"}}
+
+
{{range .AdminUsers}}{{else}}{{end}}
{{t .Locale "field.username"}}{{t .Locale "field.email"}}{{t .Locale "admin.devices"}}{{t .Locale "admin.vaults"}}{{t .Locale "admin.lastSeen"}}{{t .Locale "admin.created"}}{{t .Locale "device.status"}}{{t .Locale "common.actions"}}
{{.Username}}{{.Email}}{{.Devices}}{{.Vaults}}{{webtime $.Locale .LastSeen}}{{webtime $.Locale .CreatedAt}}{{if .Blocked}}{{t $.Locale "admin.blocked"}}{{else if .Confirmed}}{{t $.Locale "device.active"}}{{else}}{{t $.Locale "admin.unconfirmed"}}{{end}}
{{t $.Locale "admin.manage"}}{{if not .Confirmed}}
{{end}}
{{t .Locale "admin.noUsers"}}
+ +
+{{end}} diff --git a/internal/server/web/templates/admin_vaults.html b/internal/server/web/templates/admin_vaults.html new file mode 100644 index 0000000..042a439 --- /dev/null +++ b/internal/server/web/templates/admin_vaults.html @@ -0,0 +1,7 @@ +{{define "admin_vaults"}}{{template "layout" .}}{{end}} +{{define "content"}} +
{{template "admin_nav" .}}
+

{{t .Locale "admin.storage"}}

{{t .Locale "admin.vaults"}}

+
{{range .Vaults}}{{else}}{{end}}
{{t .Locale "admin.user"}}{{t .Locale "device.vault"}}{{t .Locale "admin.devices"}}{{t .Locale "admin.operations"}}{{t .Locale "admin.lastActivity"}}
{{.User}}{{short .Vault 24}}{{.Devices}}{{.Operations}}{{.LastActivity}}
{{t .Locale "admin.noVaults"}}
+
+{{end}} diff --git a/internal/server/web/templates/confirm.html b/internal/server/web/templates/confirm.html index 7188bca..464c4c9 100644 --- a/internal/server/web/templates/confirm.html +++ b/internal/server/web/templates/confirm.html @@ -1,2 +1,2 @@ {{define "confirm"}}{{template "layout" .}}{{end}} -{{define "content"}}

{{t .Locale "auth.account"}}

{{t .Locale "confirm.title"}}

{{t .Locale "confirm.description"}}

{{end}} +{{define "content"}}

{{t .Locale "auth.account"}}

{{t .Locale "confirm.title"}}

{{t .Locale "confirm.description"}}

{{end}} diff --git a/internal/server/web/templates/dashboard.html b/internal/server/web/templates/dashboard.html index c2d448a..f7261b1 100644 --- a/internal/server/web/templates/dashboard.html +++ b/internal/server/web/templates/dashboard.html @@ -1,2 +1,7 @@ {{define "dashboard"}}{{template "layout" .}}{{end}} -{{define "content"}}

{{t .Locale "user.account"}}

{{.UserName}}

{{.Email}}

{{if .Flash}}{{end}}

{{t .Locale "user.devices"}}

{{if .Devices}}
{{range .Devices}}{{end}}
{{t .Locale "device.name"}}{{t .Locale "device.vault"}}{{t .Locale "device.version"}}{{t .Locale "device.lastSeen"}}{{t .Locale "device.status"}}{{t .Locale "common.actions"}}
{{.Name}}{{short .Vault 18}}{{.ClientVersion}}{{.LastSeen}}{{if .Revoked}}{{t $.Locale "device.revoked"}}{{else}}{{t $.Locale "device.active"}}{{end}}{{if not .Revoked}}
{{end}}
{{else}}

{{t .Locale "user.noDevices"}}

{{end}}
{{end}} +{{define "content"}} +

{{t .Locale "user.account"}}

{{.UserName}}

{{.Email}} · {{if .UserConfirmed}}{{t .Locale "user.emailConfirmed"}}{{else}}{{t .Locale "user.emailUnconfirmed"}}{{end}}

+{{if .Flash}}

{{t .Locale .Flash}}

{{end}} +

{{t .Locale "user.connectInstruction"}}

+

{{t .Locale "user.devices"}}

{{if .Devices}}
{{range .Devices}}{{end}}
{{t .Locale "device.name"}}{{t .Locale "device.vault"}}{{t .Locale "device.version"}}{{t .Locale "device.created"}}{{t .Locale "device.lastSeen"}}{{t .Locale "device.status"}}{{t .Locale "common.actions"}}
{{.Name}}{{short .Vault 18}}{{.ClientVersion}}{{webtime $.Locale .CreatedAt}}{{webtime $.Locale .LastSeen}}{{if .Revoked}}{{t $.Locale "device.revoked"}}{{else}}{{t $.Locale "device.active"}}{{end}}{{if not .Revoked}}
{{end}}
{{else}}

{{t .Locale "user.noDevices"}}

{{end}}
+{{end}} diff --git a/internal/server/web/templates/forgot.html b/internal/server/web/templates/forgot.html index 1fbe341..f06c0f4 100644 --- a/internal/server/web/templates/forgot.html +++ b/internal/server/web/templates/forgot.html @@ -1,5 +1,5 @@ {{define "forgot"}}{{template "layout" .}}{{end}} {{define "content"}}

{{t .Locale "auth.account"}}

{{t .Locale "auth.forgotTitle"}}

{{t .Locale "auth.forgotDescription"}}

-

{{t .Locale "auth.backLogin"}}

+

{{t .Locale "auth.backLogin"}}

{{end}} diff --git a/internal/server/web/templates/home.html b/internal/server/web/templates/home.html index 4f26f6c..a80a881 100644 --- a/internal/server/web/templates/home.html +++ b/internal/server/web/templates/home.html @@ -5,6 +5,6 @@

{{t .Locale "home.heading"}}

{{t .Locale "home.description"}}

{{t .Locale "home.login"}}{{if .AllowRegistration}}{{t .Locale "home.register"}}{{end}}
-

{{t .Locale "home.version"}} {{.Version}} · {{.BuildCommit}} · {{.Status}}

+

{{t .Locale "home.version"}} {{.Version}} · {{.BuildCommit}} · {{statuslabel .Locale .Status}}

{{end}} diff --git a/internal/server/web/templates/layout.html b/internal/server/web/templates/layout.html index 0a54bcc..cad2e9b 100644 --- a/internal/server/web/templates/layout.html +++ b/internal/server/web/templates/layout.html @@ -17,7 +17,7 @@
- {{if .CSRF}}{{end}} +
+

{{t .Locale "auth.forgot"}}{{if .AllowRegistration}} · {{t .Locale "auth.register"}}{{end}}

{{end}} diff --git a/internal/server/web/templates/register.html b/internal/server/web/templates/register.html index 0695654..42e0f63 100644 --- a/internal/server/web/templates/register.html +++ b/internal/server/web/templates/register.html @@ -2,6 +2,6 @@ {{define "content"}}

{{t .Locale "auth.account"}}

{{t .Locale "auth.registerTitle"}}

{{if .Flash}}{{end}} -
+

{{t .Locale "auth.haveAccount"}}

{{end}} diff --git a/internal/server/web/templates/reset.html b/internal/server/web/templates/reset.html index dd0eef8..a245fe2 100644 --- a/internal/server/web/templates/reset.html +++ b/internal/server/web/templates/reset.html @@ -2,5 +2,5 @@ {{define "content"}}

{{t .Locale "auth.account"}}

{{t .Locale "auth.resetTitle"}}

{{if .Flash}}{{end}} -
+
{{end}} diff --git a/internal/server/web/templates/unavailable.html b/internal/server/web/templates/unavailable.html new file mode 100644 index 0000000..e3fc343 --- /dev/null +++ b/internal/server/web/templates/unavailable.html @@ -0,0 +1,2 @@ +{{define "unavailable"}}{{template "layout" .}}{{end}} +{{define "content"}}

{{t .Locale "home.eyebrow"}}

{{t .Locale .Heading}}

{{t .Locale .Message}}

{{end}} diff --git a/internal/server/web/templates/vault_detail.html b/internal/server/web/templates/vault_detail.html index 4a6a4ff..955cdd7 100644 --- a/internal/server/web/templates/vault_detail.html +++ b/internal/server/web/templates/vault_detail.html @@ -1,2 +1,10 @@ {{define "vault_detail"}}{{template "layout" .}}{{end}} -{{define "content"}}

{{t .Locale "admin.vaults"}}

{{short .VaultDetail.Vault 32}}

{{.VaultDetail.User}}

{{.VaultDetail.Devices}}{{t .Locale "admin.devices"}}
{{.VaultDetail.Operations}}{{t .Locale "admin.operations"}}
{{.VaultDetail.BlobBytes}}{{t .Locale "admin.blobBytes"}}
{{t .Locale "admin.lastActivity"}}
{{.VaultDetail.LastActivity}}

{{t .Locale "admin.vaultPrivacy"}}

{{t .Locale "common.back"}}
{{end}} +{{define "content"}} +
{{template "admin_nav" .}}
+

{{t .Locale "admin.vaults"}}

{{short .VaultDetail.Vault 32}}

{{.VaultDetail.User}}

+
{{.VaultDetail.Devices}}{{t .Locale "admin.devices"}} · {{.VaultDetail.Active}} {{t .Locale "admin.active"}} · {{.VaultDetail.Revoked}} {{t .Locale "admin.revoked"}}
{{.VaultDetail.Operations}}{{t .Locale "admin.operations"}} · {{t .Locale "admin.sequence"}} {{.VaultDetail.Sequence}}
{{webbytes .VaultDetail.BlobBytes}}{{t .Locale "admin.blobBytes"}}
+
{{t .Locale "admin.lastActivity"}}
{{webtime .Locale .VaultDetail.LastActivity}}

{{t .Locale "admin.vaultPrivacy"}}

+

{{t .Locale "admin.devices"}}

{{range .VaultDevices}}{{else}}{{end}}
{{t .Locale "device.name"}}{{t .Locale "device.version"}}{{t .Locale "admin.ip"}}{{t .Locale "device.lastSeen"}}{{t .Locale "device.status"}}
{{.Name}}{{.Version}}{{.LastIP}}{{webtime $.Locale .LastSeen}}{{if .Revoked}}{{t $.Locale "device.revoked"}}{{else}}{{t $.Locale "device.active"}}{{end}}
{{t .Locale "admin.noDevices"}}
+ {{t .Locale "common.back"}} +
+{{end}} diff --git a/internal/server/web_admin.go b/internal/server/web_admin.go index b39aaf6..43378f9 100644 --- a/internal/server/web_admin.go +++ b/internal/server/web_admin.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "log" "net/http" + "net/url" "strconv" "strings" "time" @@ -13,6 +14,38 @@ import ( "golang.org/x/crypto/bcrypt" ) +type oneTimeWebSecret struct { + Value string + ExpiresAt time.Time +} + +// storeAdminOneTimeSecret keeps a generated password only long enough for the +// currently authenticated administrator to retrieve it once. It is never +// written to the database, URL, audit log, or cookie. +func (s *Server) storeAdminOneTimeSecret(sessionToken, secret string) { + s.secretMu.Lock() + defer s.secretMu.Unlock() + now := time.Now().UTC() + for key, value := range s.webSecrets { + if !now.Before(value.ExpiresAt) { + delete(s.webSecrets, key) + } + } + s.webSecrets[sha256Hex(sessionToken)] = oneTimeWebSecret{Value: secret, ExpiresAt: now.Add(5 * time.Minute)} +} + +func (s *Server) takeAdminOneTimeSecret(sessionToken string) string { + s.secretMu.Lock() + defer s.secretMu.Unlock() + key := sha256Hex(sessionToken) + value, ok := s.webSecrets[key] + delete(s.webSecrets, key) + if !ok || !time.Now().UTC().Before(value.ExpiresAt) { + return "" + } + return value.Value +} + func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { methodNotAllowed(w, http.MethodGet) @@ -21,6 +54,28 @@ func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/dashboard", http.StatusFound) } +func (s *Server) handleAdminPasswordResult(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, http.MethodGet) + return + } + if !s.requireAdminCookie(w, r) { + return + } + cookie, err := r.Cookie("admin_session") + if err != nil { + http.Redirect(w, r, "/admin/users", http.StatusSeeOther) + return + } + secret := s.takeAdminOneTimeSecret(cookie.Value) + if secret == "" { + http.Redirect(w, r, "/admin/users", http.StatusSeeOther) + return + } + w.Header().Set("Cache-Control", "no-store, max-age=0") + s.renderPage(w, r, "admin_password_result", webPage{Title: "admin.resetPassword", Admin: true, AdminPage: "users", OneTimeSecret: secret}) +} + func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { methodNotAllowed(w, http.MethodGet) @@ -35,7 +90,7 @@ func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request) return } var d webVaultDetail - if err := s.db.QueryRow(`SELECT COALESCE((SELECT username FROM server_users WHERE id=?),''), COUNT(DISTINCT d.id), COUNT(DISTINCT o.op_id), COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE d.user_id=? AND d.vault_id=?`, userID, userID, vaultID).Scan(&d.User, &d.Devices, &d.Operations, &d.LastActivity); err != nil { + if err := s.db.QueryRow(`SELECT COALESCE((SELECT username FROM server_users WHERE id=?),''), COUNT(DISTINCT d.id), COUNT(DISTINCT CASE WHEN COALESCE(d.revoked_at,'')='' THEN d.id END), COUNT(DISTINCT CASE WHEN COALESCE(d.revoked_at,'')!='' THEN d.id END), COUNT(DISTINCT o.op_id), COALESCE(MAX(o.server_sequence),0), COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE d.user_id=? AND d.vault_id=?`, userID, userID, vaultID).Scan(&d.User, &d.Devices, &d.Active, &d.Revoked, &d.Operations, &d.Sequence, &d.LastActivity); err != nil { jsonInternalError(w, err) return } @@ -44,7 +99,31 @@ func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request) jsonInternalError(w, err) return } - s.renderPage(w, r, "vault_detail", webPage{Title: "admin.vaults", Admin: true, VaultDetail: d}) + rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(u.username,''),COALESCE(d.vault_id,''),COALESCE(d.client_version,''),COALESCE(d.last_ip,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at,COALESCE(d.token_prefix,''),COALESCE(d.token_suffix,'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id WHERE d.user_id=? AND d.vault_id=? ORDER BY d.created_at DESC`, userID, vaultID) + if err != nil { + jsonInternalError(w, err) + return + } + defer rows.Close() + var devices []webAdminDevice + for rows.Next() { + var device webAdminDevice + var revoked, prefix, suffix string + if err := rows.Scan(&device.ID, &device.Name, &device.User, &device.Vault, &device.Version, &device.LastIP, &device.LastSeen, &revoked, &device.CreatedAt, &prefix, &suffix); err != nil { + jsonInternalError(w, err) + return + } + device.Revoked = revoked != "" + if prefix != "" || suffix != "" { + device.TokenHint = prefix + "…" + suffix + } + devices = append(devices, device) + } + if err := rows.Err(); err != nil { + jsonInternalError(w, err) + return + } + s.renderPage(w, r, "vault_detail", webPage{Title: "admin.vaults", Admin: true, AdminPage: "vaults", VaultDetail: d, VaultDevices: devices}) } func (s *Server) handleAdminCreateUserWeb(w http.ResponseWriter, r *http.Request) { @@ -122,6 +201,20 @@ func (s *Server) handleAdminWeb(w http.ResponseWriter, r *http.Request) { } data := webPage{Title: "admin." + page, Admin: true, AdminPage: page, Stats: stats, Health: s.healthStatus(r.Context())} switch page { + case "dashboard": + data.Audit, _, err = s.webAudit(webList{Page: 1, PerPage: 5}) + if err == nil { + data.AdminDevices, _, err = s.webAdminDevices(webList{Page: 1, PerPage: 5}) + } + if !data.Health.DatabaseReachable || !data.Health.BlobStorageWritable { + data.Warnings = append(data.Warnings, "admin.warningReadiness") + } + if s.smtpGet("smtp_host") == "" { + data.Warnings = append(data.Warnings, "admin.warningSMTP") + } + if stats.Operations > 100000 { + data.Warnings = append(data.Warnings, "admin.warningOperations") + } case "users": data.List = webListFromRequest(r) data.AdminUsers, data.List, err = s.webAdminUsers(data.List) @@ -135,21 +228,38 @@ func (s *Server) handleAdminWeb(w http.ResponseWriter, r *http.Request) { data.Audit, data.List, err = s.webAudit(data.List) case "settings": data.SMTP = s.webSMTP() + switch r.URL.Query().Get("flash") { + case "settings_saved": + data.Flash = "admin.settingsSaved" + case "smtp_saved": + data.Flash = "admin.smtpSaved" + case "smtp_test_passed": + data.Flash = "admin.smtpPassed" + case "smtp_test_failed": + data.Flash = "admin.smtpTestFailed" + } + case "storage": + if r.URL.Query().Get("flash") == "cleanup_done" { + data.Flash = "admin.cleanupDone" + } } if err != nil { log.Printf("admin %s: %v", page, err) s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/admin/dashboard") return } - if page == "settings" { - s.renderPage(w, r, "admin_settings", data) - return - } - s.renderPage(w, r, "admin", data) + s.renderPage(w, r, "admin_"+page, data) } func webListFromRequest(r *http.Request) webList { - list := webList{Query: strings.TrimSpace(r.URL.Query().Get("q")), Status: strings.TrimSpace(r.URL.Query().Get("status")), Page: 1, PerPage: 25} + trim := func(value string) string { + value = strings.TrimSpace(value) + if len(value) > 160 { + return value[:160] + } + return value + } + list := webList{Query: trim(r.URL.Query().Get("q")), Status: trim(r.URL.Query().Get("status")), Sort: trim(r.URL.Query().Get("sort")), User: trim(r.URL.Query().Get("user")), Vault: trim(r.URL.Query().Get("vault")), Version: trim(r.URL.Query().Get("version")), Event: trim(r.URL.Query().Get("event")), Severity: trim(r.URL.Query().Get("severity")), Page: 1, PerPage: 25} if value, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && value > 0 { list.Page = value } @@ -198,9 +308,14 @@ func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) { return nil, list, err } list = finishWebList(list, total) + order := map[string]string{"username": "u.username COLLATE NOCASE ASC", "last_seen": "COALESCE(u.last_seen,'') DESC", "created": "u.created_at DESC"}[list.Sort] + if order == "" { + list.Sort = "created" + order = "u.created_at DESC" + } queryArgs := append([]interface{}{}, args...) queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage) - rows, err := s.db.Query(`SELECT u.id,u.username,u.email,u.confirmed,u.blocked,u.created_at,COALESCE(u.last_seen,''),COUNT(ud.device_id) FROM server_users u LEFT JOIN server_user_devices ud ON ud.user_id=u.id`+where+` GROUP BY u.id ORDER BY u.created_at DESC LIMIT ? OFFSET ?`, queryArgs...) + rows, err := s.db.Query(`SELECT u.id,u.username,u.email,u.confirmed,u.blocked,u.created_at,COALESCE(u.last_seen,''),COUNT(ud.device_id),(SELECT COUNT(DISTINCT vd.vault_id) FROM server_devices vd WHERE vd.user_id=u.id AND COALESCE(vd.vault_id,'')!='') FROM server_users u LEFT JOIN server_user_devices ud ON ud.user_id=u.id`+where+` GROUP BY u.id ORDER BY `+order+` LIMIT ? OFFSET ?`, queryArgs...) if err != nil { return nil, list, err } @@ -209,7 +324,7 @@ func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) { for rows.Next() { var u webAdminUser var confirmed, blocked int - if err := rows.Scan(&u.ID, &u.Username, &u.Email, &confirmed, &blocked, &u.CreatedAt, &u.LastSeen, &u.Devices); err != nil { + if err := rows.Scan(&u.ID, &u.Username, &u.Email, &confirmed, &blocked, &u.CreatedAt, &u.LastSeen, &u.Devices, &u.Vaults); err != nil { return nil, list, err } u.Confirmed = confirmed != 0 @@ -222,27 +337,44 @@ func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) { func (s *Server) webAdminDevices(list webList) ([]webAdminDevice, webList, error) { where := "" args := []interface{}{} - if list.Query != "" { - where = " WHERE (d.name LIKE ? OR u.username LIKE ? OR d.vault_id LIKE ?)" - like := "%" + list.Query + "%" - args = append(args, like, like, like) - } - if list.Status == "active" || list.Status == "revoked" { - condition := map[string]string{"active": "COALESCE(d.revoked_at,'')=''", "revoked": "COALESCE(d.revoked_at,'')!=''"}[list.Status] + addCondition := func(condition string, values ...interface{}) { if where == "" { where = " WHERE " + condition } else { where += " AND " + condition } + args = append(args, values...) + } + if list.Query != "" { + like := "%" + list.Query + "%" + addCondition("(d.name LIKE ? OR u.username LIKE ? OR d.vault_id LIKE ?)", like, like, like) + } + if list.Status == "active" || list.Status == "revoked" { + condition := map[string]string{"active": "COALESCE(d.revoked_at,'')=''", "revoked": "COALESCE(d.revoked_at,'')!=''"}[list.Status] + addCondition(condition) + } + if list.User != "" { + addCondition("u.username LIKE ?", "%"+list.User+"%") + } + if list.Vault != "" { + addCondition("d.vault_id LIKE ?", "%"+list.Vault+"%") + } + if list.Version != "" { + addCondition("d.client_version LIKE ?", "%"+list.Version+"%") } var total int if err := s.db.QueryRow(`SELECT COUNT(*) FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id`+where, args...).Scan(&total); err != nil { return nil, list, err } list = finishWebList(list, total) + order := map[string]string{"name": "d.name COLLATE NOCASE ASC", "last_seen": "COALESCE(d.last_seen,'') DESC", "created": "d.created_at DESC"}[list.Sort] + if order == "" { + list.Sort = "created" + order = "d.created_at DESC" + } queryArgs := append([]interface{}{}, args...) queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage) - rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(u.username,''),COALESCE(d.vault_id,''),COALESCE(d.client_version,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id`+where+` ORDER BY d.created_at DESC LIMIT ? OFFSET ?`, queryArgs...) + rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(u.username,''),COALESCE(d.vault_id,''),COALESCE(d.client_version,''),COALESCE(d.last_ip,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at,COALESCE(d.token_prefix,''),COALESCE(d.token_suffix,'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id`+where+` ORDER BY `+order+` LIMIT ? OFFSET ?`, queryArgs...) if err != nil { return nil, list, err } @@ -250,10 +382,13 @@ func (s *Server) webAdminDevices(list webList) ([]webAdminDevice, webList, error var out []webAdminDevice for rows.Next() { var d webAdminDevice - var revoked string - if err := rows.Scan(&d.ID, &d.Name, &d.User, &d.Vault, &d.Version, &d.LastSeen, &revoked, &d.CreatedAt); err != nil { + var revoked, prefix, suffix string + if err := rows.Scan(&d.ID, &d.Name, &d.User, &d.Vault, &d.Version, &d.LastIP, &d.LastSeen, &revoked, &d.CreatedAt, &prefix, &suffix); err != nil { return nil, list, err } + if prefix != "" || suffix != "" { + d.TokenHint = prefix + "…" + suffix + } d.Revoked = revoked != "" if d.LastSeen == "" { d.LastSeen = "—" @@ -283,19 +418,37 @@ func (s *Server) webVaults() ([]webVault, error) { func (s *Server) webAudit(list webList) ([]webAudit, webList, error) { where := "" args := []interface{}{} + addCondition := func(condition string, values ...interface{}) { + if where == "" { + where = " WHERE " + condition + } else { + where += " AND " + condition + } + args = append(args, values...) + } if list.Query != "" { - where = " WHERE (event_type LIKE ? OR user_id LIKE ? OR device_id LIKE ?)" like := "%" + list.Query + "%" - args = append(args, like, like, like) + addCondition("(a.event_type LIKE ? OR a.user_id LIKE ? OR a.device_id LIKE ?)", like, like, like) + } + if list.Event != "" { + addCondition("a.event_type LIKE ?", "%"+list.Event+"%") + } + if list.User != "" { + addCondition("a.user_id LIKE ?", "%"+list.User+"%") + } + if list.Severity == "error" { + addCondition("(a.event_type LIKE '%failed%' OR a.event_type LIKE '%error%')") + } else if list.Severity == "warning" { + addCondition("a.event_type LIKE '%rate_limit%'") } var total int - if err := s.db.QueryRow("SELECT COUNT(*) FROM server_audit_log"+where, args...).Scan(&total); err != nil { + if err := s.db.QueryRow("SELECT COUNT(*) FROM server_audit_log a"+where, args...).Scan(&total); err != nil { return nil, list, err } list = finishWebList(list, total) queryArgs := append([]interface{}{}, args...) queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage) - rows, err := s.db.Query(`SELECT event_type,COALESCE(user_id,''),COALESCE(device_id,''),created_at FROM server_audit_log`+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, queryArgs...) + rows, err := s.db.Query(`SELECT a.event_type,COALESCE(u.username,a.user_id,''),COALESCE(d.name,a.device_id,''),COALESCE(a.ip,''),COALESCE(a.message,''),a.created_at FROM server_audit_log a LEFT JOIN server_users u ON u.id=a.user_id LEFT JOIN server_devices d ON d.id=a.device_id`+where+` ORDER BY a.id DESC LIMIT ? OFFSET ?`, queryArgs...) if err != nil { return nil, list, err } @@ -303,14 +456,25 @@ func (s *Server) webAudit(list webList) ([]webAudit, webList, error) { var out []webAudit for rows.Next() { var a webAudit - if err := rows.Scan(&a.Event, &a.User, &a.Device, &a.At); err != nil { + if err := rows.Scan(&a.Event, &a.User, &a.Device, &a.IP, &a.Message, &a.At); err != nil { return nil, list, err } + a.Severity = auditSeverity(a.Event) out = append(out, a) } return out, list, rows.Err() } +func auditSeverity(event string) string { + if strings.Contains(event, "failed") || strings.Contains(event, "error") { + return "error" + } + if strings.Contains(event, "rate_limit") || strings.Contains(event, "revoked") || strings.Contains(event, "blocked") { + return "warning" + } + return "info" +} + func (s *Server) webSMTP() webSMTP { return webSMTP{Host: s.smtpGet("smtp_host"), Port: s.smtpGet("smtp_port"), User: s.smtpGet("smtp_user"), Security: s.smtpGet("smtp_security"), From: s.smtpGet("smtp_from"), ServerURL: s.smtpGet("server_url")} } @@ -343,10 +507,19 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) { if locale != "ru" && locale != "en" { locale = "en" } + publicURL := strings.TrimRight(strings.TrimSpace(r.FormValue("public_url")), "/") + if publicURL != "" { + parsed, err := url.ParseRequestURI(publicURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + s.renderWebError(w, r, http.StatusBadRequest, "error.invalidPublicURL", "/admin/settings") + return + } + } s.cfg.mu.Lock() s.cfg.Web.DefaultLocale = locale s.cfg.Web.AllowRegistration = r.FormValue("allow_registration") == "on" s.cfg.Web.ServerName = strings.TrimSpace(r.FormValue("server_name")) + s.cfg.PublicURL = publicURL err := s.cfg.saveLocked() s.cfg.mu.Unlock() if err != nil { @@ -354,7 +527,7 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) { return } s.auditLog("web_settings_updated", "", "", s.clientIP(r), "updated by administrator") - http.Redirect(w, r, "/admin/settings", http.StatusSeeOther) + http.Redirect(w, r, "/admin/settings?flash=settings_saved", http.StatusSeeOther) case "toggle-user": if !s.adminReauth(r, session.SubjectID) { s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users") @@ -412,14 +585,36 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) { } s.auditLog("user_updated", id, "", s.clientIP(r), "updated by administrator") http.Redirect(w, r, "/admin/users", http.StatusSeeOther) + case "confirm-user": + if !s.adminReauth(r, session.SubjectID) { + s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users") + return + } + id := r.FormValue("id") + result, err := s.db.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", id) + if err != nil { + jsonInternalError(w, err) + return + } + if changed, err := result.RowsAffected(); err != nil || changed == 0 { + if err != nil { + jsonInternalError(w, err) + } else { + s.renderWebError(w, r, http.StatusNotFound, "error.badRequest", "/admin/users") + } + return + } + s.auditLog("user_confirmed", id, "", s.clientIP(r), "confirmed by administrator") + http.Redirect(w, r, "/admin/users", http.StatusSeeOther) case "reset-user-password": if !s.adminReauth(r, session.SubjectID) { s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users") return } - id, password := r.FormValue("id"), r.FormValue("new_password") - if err := validatePassword(password); err != "" { - s.renderWebError(w, r, http.StatusBadRequest, "error.passwordInvalid", "/admin/users") + id := r.FormValue("id") + password, err := randomSecret(16) + if err != nil { + jsonInternalError(w, err) return } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) @@ -446,7 +641,13 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) { return } s.auditLog("user_password_reset", id, "", s.clientIP(r), "reset by administrator") - http.Redirect(w, r, "/admin/users", http.StatusSeeOther) + cookie, err := r.Cookie("admin_session") + if err != nil { + jsonInternalError(w, err) + return + } + s.storeAdminOneTimeSecret(cookie.Value, password) + http.Redirect(w, r, "/admin/password-result", http.StatusSeeOther) case "delete-user": if !s.adminReauth(r, session.SubjectID) { s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users") @@ -483,6 +684,43 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) { } s.auditLog("device_revoked", "", id, s.clientIP(r), "revoked by administrator") http.Redirect(w, r, "/admin/devices", http.StatusSeeOther) + case "delete-device": + if !s.adminReauth(r, session.SubjectID) { + s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/devices") + return + } + id := r.FormValue("id") + tx, err := s.db.Begin() + if err != nil { + jsonInternalError(w, err) + return + } + defer tx.Rollback() + var revoked string + if err := tx.QueryRow("SELECT COALESCE(revoked_at,'') FROM server_devices WHERE id=?", id).Scan(&revoked); err != nil { + if err == sql.ErrNoRows { + s.renderWebError(w, r, http.StatusNotFound, "error.badRequest", "/admin/devices") + } else { + jsonInternalError(w, err) + } + return + } + if revoked == "" { + s.renderWebError(w, r, http.StatusConflict, "error.deviceMustBeRevoked", "/admin/devices") + return + } + for _, statement := range []string{"DELETE FROM server_user_devices WHERE device_id=?", "DELETE FROM server_devices WHERE id=?"} { + if _, err := tx.Exec(statement, id); err != nil { + jsonInternalError(w, err) + return + } + } + if err := tx.Commit(); err != nil { + jsonInternalError(w, err) + return + } + s.auditLog("device_deleted", "", id, s.clientIP(r), "revoked device deleted by administrator") + http.Redirect(w, r, "/admin/devices", http.StatusSeeOther) case "smtp": if !s.adminReauth(r, session.SubjectID) { s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/settings") @@ -511,7 +749,29 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) { return } s.auditLog("smtp_settings_updated", "", "", s.clientIP(r), "updated by administrator") - http.Redirect(w, r, "/admin/settings", http.StatusSeeOther) + http.Redirect(w, r, "/admin/settings?flash=smtp_saved", http.StatusSeeOther) + case "smtp-test": + if !s.adminReauth(r, session.SubjectID) { + s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/settings") + return + } + smtp := s.webSMTP() + to := strings.TrimSpace(r.FormValue("test_to")) + if to == "" { + to = smtp.From + } + if smtp.Host == "" || smtp.Port == "" || smtp.From == "" || to == "" { + http.Redirect(w, r, "/admin/settings?flash=smtp_test_failed", http.StatusSeeOther) + return + } + if err := s.smtpTest(smtp.Host, smtp.Port, smtp.User, s.smtpGet("smtp_pass"), smtp.Security, smtp.From, to); err != nil { + log.Printf("admin SMTP test failed: %v", err) + s.auditLog("smtp_test_failed", "", "", s.clientIP(r), "tested by administrator") + http.Redirect(w, r, "/admin/settings?flash=smtp_test_failed", http.StatusSeeOther) + return + } + s.auditLog("smtp_test_passed", "", "", s.clientIP(r), "tested by administrator") + http.Redirect(w, r, "/admin/settings?flash=smtp_test_passed", http.StatusSeeOther) case "cleanup": if !s.adminReauth(r, session.SubjectID) { s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/storage") @@ -522,7 +782,7 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) { return } s.auditLog("retention_cleanup", "", "", s.clientIP(r), "safe retention cleanup run by administrator") - http.Redirect(w, r, "/admin/storage", http.StatusSeeOther) + http.Redirect(w, r, "/admin/storage?flash=cleanup_done", http.StatusSeeOther) default: s.renderWebError(w, r, http.StatusBadRequest, "error.badRequest", "/admin/dashboard") } diff --git a/internal/server/web_locale.go b/internal/server/web_locale.go index 27e0307..1f159ce 100644 --- a/internal/server/web_locale.go +++ b/internal/server/web_locale.go @@ -1,11 +1,15 @@ package server import ( + "crypto/subtle" "net/http" "strings" ) -const webLocaleCookieName = "verstak_locale" +const ( + webLocaleCookieName = "verstak_locale" + webLocaleCSRFCookieName = "verstak_locale_csrf" +) func isSupportedWebLocale(locale string) bool { return locale == "en" || locale == "ru" @@ -69,3 +73,38 @@ func (s *Server) setWebLocale(w http.ResponseWriter, r *http.Request, locale str Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: 365 * 24 * 60 * 60, }) } + +// webLocaleCSRF protects the public language-preference form without +// conflating it with session CSRF tokens. Its value is rendered by the server, +// while the matching cookie is HttpOnly and never read by client-side code. +func (s *Server) webLocaleCSRF(w http.ResponseWriter, r *http.Request) string { + if cookie, err := r.Cookie(webLocaleCSRFCookieName); err == nil && cookie.Value != "" { + return cookie.Value + } + token, err := randomSecret(32) + if err != nil { + return "" + } + http.SetCookie(w, &http.Cookie{ + Name: webLocaleCSRFCookieName, Value: token, Path: "/", HttpOnly: true, + Secure: s.requestIsHTTPS(r), SameSite: http.SameSiteStrictMode, MaxAge: 24 * 60 * 60, + }) + return token +} + +func (s *Server) verifyWebLocaleCSRF(r *http.Request) bool { + cookie, err := r.Cookie(webLocaleCSRFCookieName) + if err != nil || cookie.Value == "" || !s.sameOrigin(r) { + return false + } + candidate := r.FormValue("locale_csrf") + return candidate != "" && subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(candidate)) == 1 +} + +func (s *Server) requirePublicWebMutation(w http.ResponseWriter, r *http.Request, back string) bool { + if s.verifyWebLocaleCSRF(r) { + return true + } + s.renderWebError(w, r, http.StatusForbidden, "error.tryAgain", back) + return false +} diff --git a/internal/server/web_locale_test.go b/internal/server/web_locale_test.go index 57c827a..68f760e 100644 --- a/internal/server/web_locale_test.go +++ b/internal/server/web_locale_test.go @@ -21,6 +21,16 @@ func TestResolveWebLocaleCookieOverridesSystemAcceptLanguage(t *testing.T) { } } +func TestFormatWebTimeUsesSelectedLocale(t *testing.T) { + stamp := "2026-07-17T13:45:00Z" + if got := formatWebTime("ru", stamp); !strings.Contains(got, "17.07.2026") { + t.Fatalf("Russian timestamp = %q", got) + } + if got := formatWebTime("en", stamp); !strings.Contains(got, "Jul 17, 2026") { + t.Fatalf("English timestamp = %q", got) + } +} + func TestTranslationCatalogsHaveMatchingKeysAndHideUnknownKeys(test *testing.T) { for key := range _translations["en"] { if _, ok := _translations["ru"][key]; !ok { @@ -56,6 +66,20 @@ func TestEmbeddedTemplatesUseExternalAssetsAndNoInlineEventHandlers(t *testing.T } } +func TestConfirmationUsesLocalDialogInsteadOfBrowserPrompt(t *testing.T) { + layout, err := webFS.ReadFile("web/templates/layout.html") + if err != nil { + t.Fatal(err) + } + script, err := webFS.ReadFile("web/static/app.js") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(layout), `id="confirm-dialog"`) || !strings.Contains(string(script), ".showModal()") || strings.Contains(string(script), "window.confirm") { + t.Fatalf("local confirmation dialog is missing or browser prompt remains") + } +} + func TestEmbeddedTemplateTranslationKeysExist(t *testing.T) { entries, err := fs.Glob(webFS, "web/templates/*.html") if err != nil { @@ -102,6 +126,22 @@ func TestPublicHomeUsesSharedLocalizedTemplateLayout(t *testing.T) { } } +func TestPublicHomeUsesUnavailablePageWhenReadinessFails(t *testing.T) { + s, err := newTestServer(t) + if err != nil { + t.Fatal(err) + } + s.SetupRoutes() + if err := s.db.Close(); err != nil { + t.Fatal(err) + } + res := httptest.NewRecorder() + s.Handler().ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/", nil)) + if res.Code != http.StatusServiceUnavailable || !strings.Contains(res.Body.String(), ``) { + t.Fatalf("unavailable page=%d: %s", res.Code, res.Body.String()) + } +} + func TestPublicTemplateRoutesRenderInBothLocales(t *testing.T) { s, err := newTestServer(t) if err != nil { @@ -125,6 +165,39 @@ func TestPublicTemplateRoutesRenderInBothLocales(t *testing.T) { } } +func TestAdminPagesRenderWithActiveNavigationInBothLocales(t *testing.T) { + s, err := newTestServer(t) + if err != nil { + t.Fatal(err) + } + defer s.Close() + s.SetupRoutes() + if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','alice@example.test','hash',1,'2026-01-01T00:00:00Z')"); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,created_at) VALUES ('d1','Laptop','legacy','u1','vault-a','2026-01-01T00:00:00Z')"); err != nil { + t.Fatal(err) + } + token, _, err := s.createSession(sessionScopeAdmin, "admin") + if err != nil { + t.Fatal(err) + } + for _, locale := range []string{"ru", "en"} { + for _, tc := range []struct{ path, active string }{ + {"/admin/dashboard", "/admin/dashboard"}, {"/admin/users", "/admin/users"}, {"/admin/devices", "/admin/devices"}, {"/admin/vaults", "/admin/vaults"}, {"/admin/vault/?user=u1&vault=vault-a", "/admin/vaults"}, {"/admin/storage", "/admin/storage"}, {"/admin/audit", "/admin/audit"}, {"/admin/settings", "/admin/settings"}, {"/admin/diagnostics", "/admin/diagnostics"}, + } { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + req.AddCookie(&http.Cookie{Name: "admin_session", Value: token}) + req.AddCookie(&http.Cookie{Name: webLocaleCookieName, Value: locale}) + res := httptest.NewRecorder() + s.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), ``) || !strings.Contains(res.Body.String(), `href="`+tc.active+`" aria-current="page"`) { + t.Fatalf("%s %s = %d; active navigation missing: %s", locale, tc.path, res.Code, res.Body.String()) + } + } + } +} + func TestConfirmationPageUsesSharedTemplateAndEscapesToken(t *testing.T) { s, err := newTestServer(t) if err != nil { @@ -223,8 +296,9 @@ func TestLocaleSelectionUsesCookieAndPRG(t *testing.T) { } defer s.Close() s.SetupRoutes() - req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/login")) + req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/login&locale_csrf=locale-test-token")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "locale-test-token"}) res := httptest.NewRecorder() s.Handler().ServeHTTP(res, req) if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/login" { @@ -243,8 +317,9 @@ func TestLocaleSelectionPreservesResetQuery(t *testing.T) { } defer s.Close() s.SetupRoutes() - req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/reset%3Ftoken%3Dopaque-token")) + req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/reset%3Ftoken%3Dopaque-token&locale_csrf=locale-test-token")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "locale-test-token"}) res := httptest.NewRecorder() s.Handler().ServeHTTP(res, req) if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/reset?token=opaque-token" { @@ -252,6 +327,52 @@ func TestLocaleSelectionPreservesResetQuery(t *testing.T) { } } +func TestLocaleSelectionRejectsMissingOrMismatchedCSRF(t *testing.T) { + s, err := newTestServer(t) + if err != nil { + t.Fatal(err) + } + defer s.Close() + s.SetupRoutes() + for _, tc := range []struct { + name, body, cookie string + }{ + {"missing", "locale=ru&from=/login", ""}, + {"mismatched", "locale=ru&from=/login&locale_csrf=other", "locale-test-token"}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if tc.cookie != "" { + req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: tc.cookie}) + } + res := httptest.NewRecorder() + s.Handler().ServeHTTP(res, req) + if res.Code != http.StatusForbidden { + t.Fatalf("locale CSRF status=%d, want 403", res.Code) + } + }) + } +} + +func TestPublicWebFormsRejectMissingCSRF(t *testing.T) { + s, err := newTestServer(t) + if err != nil { + t.Fatal(err) + } + defer s.Close() + s.SetupRoutes() + for _, path := range []string{"/register", "/login", "/forgot", "/reset", "/admin/login"} { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("username=alice&email=alice%40example.test&password=password&confirm=password")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + res := httptest.NewRecorder() + s.Handler().ServeHTTP(res, req) + if res.Code != http.StatusForbidden { + t.Fatalf("%s without public CSRF = %d, want 403", path, res.Code) + } + } +} + func TestWebResponsesHaveSecurityHeaders(t *testing.T) { s, err := newTestServer(t) if err != nil { @@ -349,6 +470,198 @@ func TestAdminUserListSearchStatusAndPagination(t *testing.T) { } } +func TestAdminSettingsRendersAndSavesSMTPConfiguration(t *testing.T) { + s, err := newTestServer(t) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err := s.cfg.SetAdmin("admin", "correct horse battery staple"); err != nil { + t.Fatal(err) + } + s.SetupRoutes() + token, csrf, err := s.createSession(sessionScopeAdmin, "admin") + if err != nil { + t.Fatal(err) + } + + get := httptest.NewRequest(http.MethodGet, "/admin/settings", nil) + get.AddCookie(&http.Cookie{Name: "admin_session", Value: token}) + get.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf}) + getResult := httptest.NewRecorder() + s.Handler().ServeHTTP(getResult, get) + if getResult.Code != http.StatusOK { + t.Fatalf("settings page = %d: %s", getResult.Code, getResult.Body.String()) + } + for _, field := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_security", "smtp_from", "server_url"} { + if !strings.Contains(getResult.Body.String(), `name="`+field+`"`) { + t.Fatalf("settings page is missing SMTP field %q: %s", field, getResult.Body.String()) + } + } + + body := "csrf_token=" + csrf + "&action=smtp&smtp_host=mail.example.test&smtp_port=587&smtp_user=mailer&smtp_pass=mail-secret&smtp_security=starttls&smtp_from=sync%40example.test&server_url=https%3A%2F%2Fsync.example.test&password=correct+horse+battery+staple" + post := httptest.NewRequest(http.MethodPost, "/admin/action", strings.NewReader(body)) + post.Header.Set("Content-Type", "application/x-www-form-urlencoded") + post.AddCookie(&http.Cookie{Name: "admin_session", Value: token}) + post.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf}) + postResult := httptest.NewRecorder() + s.Handler().ServeHTTP(postResult, post) + if postResult.Code != http.StatusSeeOther || postResult.Header().Get("Location") != "/admin/settings?flash=smtp_saved" { + t.Fatalf("save SMTP = %d %q: %s", postResult.Code, postResult.Header().Get("Location"), postResult.Body.String()) + } + if got := s.smtpGet("smtp_host"); got != "mail.example.test" { + t.Fatalf("saved SMTP host = %q", got) + } +} + +func TestUserDashboardOnlyRendersOwnFilteredDevices(t *testing.T) { + s, err := newTestServer(t) + if err != nil { + t.Fatal(err) + } + defer s.Close() + s.SetupRoutes() + for _, user := range []struct{ id, name string }{{"user-a", "alice"}, {"user-b", "bob"}} { + if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES (?, ?, ?, 'hash', 1, '2026-01-01T00:00:00Z')", user.id, user.name, user.name+"@example.test"); err != nil { + t.Fatal(err) + } + } + for _, device := range []struct{ id, user, name string }{{"device-a", "user-a", "Alice laptop"}, {"device-b", "user-b", "Bob workstation"}} { + if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,created_at) VALUES (?, ?, ?, ?, 'vault', '2026-01-01T00:00:00Z')", device.id, device.name, "key-"+device.id, device.user); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec("INSERT INTO server_user_devices (user_id,device_id) VALUES (?,?)", device.user, device.id); err != nil { + t.Fatal(err) + } + } + token, _, err := s.createSession(sessionScopeUser, "user-a") + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "/dashboard?q=Alice", nil) + req.AddCookie(&http.Cookie{Name: "user_session", Value: token}) + res := httptest.NewRecorder() + s.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("dashboard=%d: %s", res.Code, res.Body.String()) + } + if !strings.Contains(res.Body.String(), "Alice laptop") || strings.Contains(res.Body.String(), "Bob workstation") { + t.Fatalf("dashboard leaked or missed device: %s", res.Body.String()) + } +} + +func TestAdminDeviceFiltersAndAuditSearchRemainBounded(t *testing.T) { + s, err := newTestServer(t) + if err != nil { + t.Fatal(err) + } + defer s.Close() + for _, user := range []struct{ id, name string }{{"u1", "alice"}, {"u2", "bob"}} { + if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES (?, ?, ?, 'hash', 1, '2026-01-01T00:00:00Z')", user.id, user.name, user.name+"@example.test"); err != nil { + t.Fatal(err) + } + } + for _, device := range []struct{ id, user, vault, version string }{{"d1", "u1", "vault-a", "2.0"}, {"d2", "u2", "vault-b", "1.0"}} { + if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,client_version,created_at) VALUES (?, ?, ?, ?, ?, ?, '2026-01-01T00:00:00Z')", device.id, device.id, "key-"+device.id, device.user, device.vault, device.version); err != nil { + t.Fatal(err) + } + } + devices, list, err := s.webAdminDevices(webList{User: "alice", Vault: "vault-a", Version: "2.0", Sort: "name", Page: 1, PerPage: 25}) + if err != nil || len(devices) != 1 || devices[0].ID != "d1" || list.Sort != "name" { + t.Fatalf("filtered devices=%+v list=%+v err=%v", devices, list, err) + } + if _, err := s.db.Exec("INSERT INTO server_audit_log (event_type,user_id,message,created_at) VALUES ('device_paired','u1','safe','2026-01-01T00:00:00Z')"); err != nil { + t.Fatal(err) + } + audit, _, err := s.webAudit(webList{Event: "' OR 1=1 --", Page: 1, PerPage: 25}) + if err != nil || len(audit) != 0 { + t.Fatalf("audit injection filter returned=%+v err=%v", audit, err) + } +} + +func TestAdminCanConfirmUnconfirmedUserWithCSRFAndReauth(t *testing.T) { + s, err := newTestServer(t) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err := s.cfg.SetAdmin("admin", "correct horse battery staple"); err != nil { + t.Fatal(err) + } + s.SetupRoutes() + if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','alice@example.test','hash',0,'2026-01-01T00:00:00Z')"); err != nil { + t.Fatal(err) + } + token, csrf, err := s.createSession(sessionScopeAdmin, "admin") + if err != nil { + t.Fatal(err) + } + body := "csrf_token=" + csrf + "&action=confirm-user&id=u1&password=correct+horse+battery+staple" + req := httptest.NewRequest(http.MethodPost, "/admin/action", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: "admin_session", Value: token}) + req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf}) + res := httptest.NewRecorder() + s.Handler().ServeHTTP(res, req) + if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/admin/users" { + t.Fatalf("confirm user=%d %q: %s", res.Code, res.Header().Get("Location"), res.Body.String()) + } + var confirmed int + if err := s.db.QueryRow("SELECT confirmed FROM server_users WHERE id='u1'").Scan(&confirmed); err != nil || confirmed != 1 { + t.Fatalf("confirmed=%d err=%v", confirmed, err) + } +} + +func TestAdminPasswordResetShowsGeneratedSecretOnce(t *testing.T) { + s, err := newTestServer(t) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err := s.cfg.SetAdmin("admin", "correct horse battery staple"); err != nil { + t.Fatal(err) + } + s.SetupRoutes() + if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','alice@example.test','old',1,'2026-01-01T00:00:00Z')"); err != nil { + t.Fatal(err) + } + adminToken, csrf, err := s.createSession(sessionScopeAdmin, "admin") + if err != nil { + t.Fatal(err) + } + if _, _, err := s.createSession(sessionScopeUser, "u1"); err != nil { + t.Fatal(err) + } + body := "csrf_token=" + csrf + "&action=reset-user-password&id=u1&password=correct+horse+battery+staple" + request := httptest.NewRequest(http.MethodPost, "/admin/action", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken}) + request.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf}) + response := httptest.NewRecorder() + s.Handler().ServeHTTP(response, request) + if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/admin/password-result" { + t.Fatalf("reset=%d %q: %s", response.Code, response.Header().Get("Location"), response.Body.String()) + } + var sessions int + if err := s.db.QueryRow("SELECT COUNT(*) FROM server_sessions WHERE scope='user' AND subject_id='u1'").Scan(&sessions); err != nil || sessions != 0 { + t.Fatalf("user sessions=%d err=%v", sessions, err) + } + resultRequest := httptest.NewRequest(http.MethodGet, "/admin/password-result", nil) + resultRequest.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken}) + resultResponse := httptest.NewRecorder() + s.Handler().ServeHTTP(resultResponse, resultRequest) + if resultResponse.Code != http.StatusOK || !strings.Contains(resultResponse.Header().Get("Cache-Control"), "no-store") || !strings.Contains(resultResponse.Body.String(), "one-time-secret") { + t.Fatalf("password result=%d headers=%v body=%s", resultResponse.Code, resultResponse.Header(), resultResponse.Body.String()) + } + secondRequest := httptest.NewRequest(http.MethodGet, "/admin/password-result", nil) + secondRequest.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken}) + secondResponse := httptest.NewRecorder() + s.Handler().ServeHTTP(secondResponse, secondRequest) + if secondResponse.Code != http.StatusSeeOther || secondResponse.Header().Get("Location") != "/admin/users" { + t.Fatalf("second password result=%d %q", secondResponse.Code, secondResponse.Header().Get("Location")) + } +} + func TestResolveWebLocaleSystemUsesAcceptLanguageAndFallsBack(t *testing.T) { cfg := DefaultConfig() cfg.Web.DefaultLocale = "en" diff --git a/internal/server/web_render.go b/internal/server/web_render.go index c7c4e99..dc4cd75 100644 --- a/internal/server/web_render.go +++ b/internal/server/web_render.go @@ -5,6 +5,8 @@ import ( "html/template" "io/fs" "net/http" + "net/url" + "strconv" "strings" "time" ) @@ -23,10 +25,15 @@ type webPage struct { DefaultLocale string Title string ServerName string + PublicURL string + TrustedProxies string + Limits Limits CurrentPath string CurrentURL string CSRF string + LocaleCSRF string Flash string + FlashError bool AllowRegistration bool Version string BuildCommit string @@ -37,9 +44,11 @@ type webPage struct { FormAction string BackURL string Token string + OneTimeSecret string Admin bool UserName string Email string + UserConfirmed bool Devices []webDevice AdminPage string Stats ServerStats @@ -47,7 +56,9 @@ type webPage struct { AdminUsers []webAdminUser AdminDevices []webAdminDevice Vaults []webVault + VaultDevices []webAdminDevice Audit []webAudit + Warnings []string SMTP webSMTP List webList VaultDetail webVaultDetail @@ -56,33 +67,50 @@ type webPage struct { type webAdminUser struct { ID, Username, Email, CreatedAt, LastSeen string Confirmed, Blocked bool - Devices int + Devices, Vaults int } type webAdminDevice struct { - ID, Name, User, Vault, Version, LastSeen, CreatedAt string - Revoked bool + ID, Name, User, Vault, Version, LastIP, LastSeen, CreatedAt, TokenHint string + Revoked bool } type webVault struct { User, UserID, Vault string Devices, Operations int LastActivity string } -type webAudit struct{ Event, User, Device, At string } +type webAudit struct{ Event, User, Device, IP, Message, Severity, At string } type webSMTP struct{ Host, Port, User, Security, From, ServerURL string } type webList struct { - Query string - Status string - Page int - PerPage int - Total int - Pages int - Previous int - Next int + Query, Status, Sort, User, Vault, Version, Event, Severity string + Page int + PerPage int + Total int + Pages int + Previous int + Next int } + +func (list webList) params(page int) string { + values := url.Values{} + for key, value := range map[string]string{"q": list.Query, "status": list.Status, "sort": list.Sort, "user": list.User, "vault": list.Vault, "version": list.Version, "event": list.Event, "severity": list.Severity} { + if value != "" { + values.Set(key, value) + } + } + if page > 1 { + values.Set("page", strconvItoa(page)) + } + if list.PerPage != 25 { + values.Set("per_page", strconvItoa(list.PerPage)) + } + return values.Encode() +} + type webVaultDetail struct { User, Vault, LastActivity string - Devices, Operations int + Devices, Active, Revoked int + Operations, Sequence int BlobBytes int64 } @@ -99,7 +127,13 @@ type webDevice struct { func newWebRenderer() (*webRenderer, error) { funcs := template.FuncMap{ - "t": func(locale, key string) string { return t(locale, key) }, + "t": func(locale, key string) string { return t(locale, key) }, + "webtime": func(locale, value string) string { return formatWebTime(locale, value) }, + "webbytes": func(value int64) string { return formatWebBytes(value) }, + "listparams": func(list webList, page int) string { return list.params(page) }, + "auditlabel": func(locale, event string) string { return auditEventLabel(locale, event) }, + "statuslabel": func(locale, status string) string { return statusLabel(locale, status) }, + "boollabel": func(locale string, value bool) string { return boolLabel(locale, value) }, "short": func(value string, length int) string { if len(value) <= length || length < 5 { return value @@ -107,12 +141,12 @@ func newWebRenderer() (*webRenderer, error) { return value[:length-1] + "…" }, } - layout, err := template.New("layout.html").Funcs(funcs).ParseFS(webFS, "web/templates/layout.html") + layout, err := template.New("layout.html").Funcs(funcs).ParseFS(webFS, "web/templates/layout.html", "web/templates/admin_nav.html") if err != nil { return nil, err } renderer := &webRenderer{templates: make(map[string]*template.Template)} - for _, page := range []string{"home", "login", "register", "forgot", "reset", "confirm", "message", "error", "admin_login", "dashboard", "admin", "admin_create_user", "vault_detail", "admin_settings"} { + for _, page := range []string{"home", "unavailable", "login", "register", "forgot", "reset", "confirm", "message", "error", "admin_login", "dashboard", "admin_dashboard", "admin_users", "admin_devices", "admin_vaults", "admin_storage", "admin_audit", "admin_diagnostics", "admin_create_user", "admin_password_result", "vault_detail", "admin_settings"} { clone, err := layout.Clone() if err != nil { return nil, err @@ -129,6 +163,74 @@ func newWebRenderer() (*webRenderer, error) { return renderer, nil } +func formatWebTime(locale, value string) string { + if value == "" { + return "—" + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return value + } + if locale == "ru" { + return parsed.Local().Format("02.01.2006 15:04") + } + return parsed.Local().Format("Jan 2, 2006 15:04") +} + +func formatWebBytes(value int64) string { + if value < 1024 { + return strconvItoa(int(value)) + " B" + } + units := []string{"KB", "MB", "GB", "TB"} + amount := float64(value) + for _, unit := range units { + amount /= 1024 + if amount < 1024 || unit == "TB" { + return strconv.FormatFloat(amount, 'f', 1, 64) + " " + unit + } + } + return "0 B" +} + +func auditEventLabel(locale, event string) string { + keys := map[string]string{ + "device_auth_failed": "audit.deviceAuthFailed", + "device_paired": "audit.devicePaired", + "device_revoked": "audit.deviceRevoked", + "device_deleted": "audit.deviceDeleted", + "rate_limit_exceeded": "audit.rateLimited", + "retention_cleanup": "audit.retentionCleanup", + "smtp_settings_updated": "audit.smtpSettingsUpdated", + "smtp_test_failed": "audit.smtpTestFailed", + "smtp_test_passed": "audit.smtpTestPassed", + "user_block_changed": "audit.userBlockChanged", + "user_confirmed": "audit.userConfirmed", + "user_created": "audit.userCreated", + "user_deleted": "audit.userDeleted", + "user_password_reset": "audit.userPasswordReset", + "user_updated": "audit.userUpdated", + "web_settings_updated": "audit.webSettingsUpdated", + } + if key := keys[event]; key != "" { + return t(locale, key) + } + return t(locale, "audit.other") +} + +func statusLabel(locale, status string) string { + if status == "ok" { + return t(locale, "status.ok") + } + return t(locale, "status.degraded") +} + +func boolLabel(locale string, value bool) string { + if value { + return t(locale, "status.available") + } + return t(locale, "status.unavailable") +} + func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page string, data webPage) { s.renderPageStatus(w, r, page, data, http.StatusOK) } @@ -142,8 +244,12 @@ func (s *Server) renderPageStatus(w http.ResponseWriter, r *http.Request, page s data.LocalePreference = s.webLocalePreference(r) data.DefaultLocale = s.cfg.Web.DefaultLocale data.ServerName = s.cfg.Web.ServerName + data.PublicURL = s.cfg.PublicURL + data.TrustedProxies = strings.Join(s.cfg.TrustedProxies, ", ") + data.Limits = s.cfg.Limits data.CurrentPath = r.URL.Path data.CurrentURL = r.URL.RequestURI() + data.LocaleCSRF = s.webLocaleCSRF(w, r) data.AllowRegistration = s.cfg.Web.AllowRegistration data.Version = Version data.BuildCommit = BuildCommit @@ -188,6 +294,10 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { return } status := s.healthStatus(r.Context()) + if status.Status != "ok" { + s.renderPageStatus(w, r, "unavailable", webPage{Title: "home.unavailableTitle", Heading: "home.unavailableTitle", Message: "home.unavailableMessage"}, http.StatusServiceUnavailable) + return + } s.renderPage(w, r, "home", webPage{Title: "home.title", Status: status.Status}) } @@ -200,6 +310,10 @@ func (s *Server) handleLocale(w http.ResponseWriter, r *http.Request) { s.renderPage(w, r, "error", webPage{Title: "error.badRequest", Heading: "error.badRequest", Message: "error.tryAgain"}) return } + if !s.verifyWebLocaleCSRF(r) { + s.renderPageStatus(w, r, "error", webPage{Title: "error.label", Heading: "error.badRequest", Message: "error.tryAgain", BackURL: "/"}, http.StatusForbidden) + return + } locale := r.FormValue("locale") if locale != "ru" && locale != "en" && locale != "system" { locale = "system" diff --git a/scripts/smoke-web-browser.mjs b/scripts/smoke-web-browser.mjs new file mode 100755 index 0000000..3cc0d86 --- /dev/null +++ b/scripts/smoke-web-browser.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +// Browser smoke driver for scripts/smoke-web.sh. It uses Node's bundled +// undici WebSocket client and Chromium DevTools; no npm package is required. +import { createRequire } from "node:module"; +import { writeFile } from "node:fs/promises"; + +const require = createRequire(import.meta.url); +const { WebSocket } = require("undici"); + +const [baseURL, outputDir] = process.argv.slice(2); +if (!baseURL || !outputDir) throw new Error("usage: smoke-web-browser.mjs "); +const debugURL = process.env.CHROME_DEBUG_URL || "http://127.0.0.1:9223"; + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +async function waitFor(check, label) { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (await check()) return; + await delay(100); + } + throw new Error(`timed out waiting for ${label}`); +} + +const targets = await (await fetch(`${debugURL}/json/list`)).json(); +const target = targets.find((item) => item.type === "page"); +if (!target?.webSocketDebuggerUrl) throw new Error("Chromium DevTools page target is unavailable"); +const socket = new WebSocket(target.webSocketDebuggerUrl); +await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); +}); + +let nextID = 1; +const pending = new Map(); +socket.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (!message.id) return; + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + if (message.error) request.reject(new Error(`${message.error.message} (${message.error.code})`)); + else request.resolve(message.result); +}); +function cdp(method, params = {}) { + const id = nextID++; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + socket.send(JSON.stringify({ id, method, params })); + }); +} +async function evaluate(expression) { + const result = await cdp("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }); + if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || "browser evaluation failed"); + return result.result.value; +} +async function navigate(path) { + await cdp("Page.navigate", { url: new URL(path, baseURL).href }); + await waitFor(async () => String(await evaluate("location.href")).startsWith(new URL(path, baseURL).href), path); + await waitFor(() => evaluate("document.readyState === 'complete'"), `${path} document readiness`); +} +async function submit(selector, values, expectedPath) { + const payload = JSON.stringify(values); + await evaluate(`(() => { const form = document.querySelector(${JSON.stringify(selector)}); if (!form) throw new Error('form not found: ${selector}'); const values = ${payload}; for (const [name, value] of Object.entries(values)) { const input = form.querySelector('[name="' + name + '"]'); if (!input) throw new Error('input not found: ' + name); input.value = value; } form.requestSubmit(); })()`); + await waitFor(async () => new URL(await evaluate("location.href")).pathname === expectedPath, expectedPath); +} +async function screenshot(name) { + const image = await cdp("Page.captureScreenshot", { format: "png" }); + await writeFile(`${outputDir}/${name}.png`, Buffer.from(image.data, "base64")); +} +async function confirmDialog(expectedPath) { + await waitFor(() => evaluate("document.querySelector('#confirm-dialog')?.open === true"), "confirmation dialog"); + await evaluate("document.querySelector('#confirm-dialog button[value=confirm]').click()"); + await waitFor(() => evaluate("document.querySelector('#confirm-dialog')?.open === false"), "confirmation dialog close"); + await delay(300); + await waitFor(async () => new URL(await evaluate("location.href")).pathname === expectedPath, expectedPath); +} + +await cdp("Page.enable"); +await cdp("Runtime.enable"); + +// Public locale selector, then a real admin login through the rendered form. +await navigate("/"); +await screenshot("public-en"); +await waitFor(() => evaluate("Boolean(document.querySelector('.locale-form select'))"), "locale selector"); +await evaluate("document.querySelector('.locale-form select').value = 'ru'; document.querySelector('.locale-form').requestSubmit()"); +await waitFor(() => evaluate("document.documentElement?.lang === 'ru'"), "Russian locale"); +await screenshot("public-ru"); + +await navigate("/admin/login"); +await submit('form[action="/admin/login"]', { username: "admin", password: "browser-smoke-admin-password" }, "/admin/dashboard"); + +// Seed data before rendering admin views, so screenshots cover non-empty +// users/devices/vaults/storage/audit states. +await navigate("/admin/create-user"); +await submit('form[action="/admin/create-user"]', { username: "browser-smoke-user", email: "browser-smoke@example.test", password: "browser-smoke-password" }, "/admin/users"); +const paired = await fetch(`${baseURL}/api/client/pair`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ login: "browser-smoke-user", password: "browser-smoke-password", device_name: "Browser smoke laptop", vault_id: "browser-smoke-vault", client_version: "smoke" }) }); +if (!paired.ok) throw new Error(`pairing smoke device failed: ${paired.status}`); + +await navigate("/admin/dashboard"); +await screenshot("admin-dashboard"); + +// Core admin navigation with populated data. +for (const [path, name] of [["/admin/users", "admin-users"], ["/admin/devices", "admin-devices"], ["/admin/vaults", "admin-vaults"], ["/admin/storage", "admin-storage"], ["/admin/audit", "admin-audit"], ["/admin/settings", "admin-settings"], ["/admin/diagnostics", "admin-diagnostics"]]) { + await navigate(path); + await screenshot(name); +} +await navigate("/admin/vaults"); +const vaultDetail = await evaluate("document.querySelector('a[href^=\"/admin/vault/\"]')?.getAttribute('href')"); +if (!vaultDetail) throw new Error("vault detail link not found"); +await navigate(vaultDetail); +await screenshot("admin-vault-detail"); +await navigate("/admin/users?q=browser-smoke-user"); + +// Exercise the destructive confirmation dialog by blocking and unblocking the +// temporary user. requestSubmit is intentionally not used for this action. +async function toggleTemporaryUser() { + await evaluate(`(() => { const row = [...document.querySelectorAll('tbody tr')].find((item) => item.textContent.includes('browser-smoke-user')); if (!row) throw new Error('temporary user row not found'); const details = row.querySelector('details'); details.open = true; const form = [...row.querySelectorAll('form')].find((item) => item.querySelector('[name=action]')?.value === 'toggle-user'); form.querySelector('[name=password]').value = 'browser-smoke-admin-password'; form.querySelector('button').click(); })()`); + await confirmDialog("/admin/users"); + await navigate("/admin/users?q=browser-smoke-user"); +} +await toggleTemporaryUser(); +await toggleTemporaryUser(); + +// A password reset is generated server-side, rendered once with no secret in +// the URL, and invalidates any prior user session. Keep it only in this test +// process so the subsequent user login exercises the generated credential. +await evaluate(`(() => { const row = [...document.querySelectorAll('tbody tr')].find((item) => item.textContent.includes('browser-smoke-user')); const details = row.querySelector('details'); details.open = true; const form = [...row.querySelectorAll('form')].find((item) => item.querySelector('[name=action]')?.value === 'reset-user-password'); form.querySelector('[name=password]').value = 'browser-smoke-admin-password'; form.querySelector('button').click(); })()`); +await confirmDialog("/admin/password-result"); +await screenshot("admin-password-result"); +const generatedPassword = await evaluate("document.querySelector('.one-time-secret')?.textContent.trim()"); +if (!generatedPassword || (await evaluate("location.href")).includes(generatedPassword)) throw new Error("one-time password result is missing or leaked into the URL"); +await navigate("/admin/users?q=browser-smoke-user"); + +// Revoke the real temporary device through the browser UI and assert the +// modal path again. +await navigate("/admin/devices?q=Browser%20smoke"); +await evaluate(`(() => { const row = [...document.querySelectorAll('tbody tr')].find((item) => item.textContent.includes('Browser smoke laptop')); if (!row) throw new Error('smoke device row not found'); const form = row.querySelector('form'); form.querySelector('[name=password]').value = 'browser-smoke-admin-password'; form.querySelector('button').click(); })()`); +await confirmDialog("/admin/devices"); + +// Server-side filters and HTML logout complete the browser path. +await navigate("/admin/audit?q=device"); +await screenshot("admin-audit-filtered"); +await evaluate("document.querySelector('form[action=\"/admin/logout\"]').requestSubmit()"); +await waitFor(async () => new URL(await evaluate("location.href")).pathname === "/admin/login", "admin logout"); + +await navigate("/login"); +await submit('form[action="/login"]', { username: "browser-smoke-user", password: generatedPassword }, "/dashboard"); +await screenshot("user-dashboard"); +await evaluate("document.querySelector('form[action=\"/logout\"]').requestSubmit()"); +await waitFor(async () => new URL(await evaluate("location.href")).pathname === "/login", "user logout"); + +socket.close(); +console.log("interactive Chromium web smoke passed"); diff --git a/scripts/smoke-web.sh b/scripts/smoke-web.sh index 9e48466..b2b7e2f 100755 --- a/scripts/smoke-web.sh +++ b/scripts/smoke-web.sh @@ -4,17 +4,21 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" TMP="$(mktemp -d /tmp/verstak-sync-web.XXXXXX)" PORT="47794" -cleanup() { kill "${SERVER_PID:-}" 2>/dev/null || true; rm -rf "$TMP"; } +DEBUG_PORT="9223" +cleanup() { kill "${BROWSER_PID:-}" 2>/dev/null || true; kill "${SERVER_PID:-}" 2>/dev/null || true; wait "${BROWSER_PID:-}" 2>/dev/null || true; wait "${SERVER_PID:-}" 2>/dev/null || true; if [[ "${KEEP_SMOKE_ARTIFACTS:-}" == "1" ]]; then printf 'web smoke artefacts retained at %s\n' "$TMP"; else rm -rf "$TMP"; fi; } trap cleanup EXIT printf '%s\n' 'browser-smoke-admin-password' > "$TMP/admin-pass" chmod 600 "$TMP/admin-pass" (cd "$ROOT" && go run ./cmd/server --data "$TMP/data" --listen "127.0.0.1:$PORT" --admin-user admin --admin-pass-file "$TMP/admin-pass") >"$TMP/server.log" 2>&1 & SERVER_PID=$! -for _ in {1..30}; do curl --noproxy '*' -fsS "http://127.0.0.1:$PORT/" >/dev/null && break; sleep 1; done +for _ in {1..30}; do curl --noproxy '*' -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1 && break; sleep 1; done curl --noproxy '*' -fsS "http://127.0.0.1:$PORT/" >/dev/null -chromium --headless --no-sandbox --disable-gpu --screenshot="$TMP/public-en.png" --window-size=1440,900 "http://127.0.0.1:$PORT/" >/dev/null -chromium --headless --no-sandbox --disable-gpu --screenshot="$TMP/login-mobile.png" --window-size=390,844 "http://127.0.0.1:$PORT/login" >/dev/null -chromium --headless --no-sandbox --disable-gpu --screenshot="$TMP/admin-login.png" --window-size=1440,900 "http://127.0.0.1:$PORT/admin/login" >/dev/null -test -s "$TMP/public-en.png" && test -s "$TMP/login-mobile.png" && test -s "$TMP/admin-login.png" -echo "web browser smoke passed (screenshots were kept only for this run: $TMP)" +chromium --headless --no-sandbox --disable-gpu --window-size=1440,900 --remote-debugging-port="$DEBUG_PORT" --user-data-dir="$TMP/chromium" about:blank >"$TMP/chromium.log" 2>&1 & +BROWSER_PID=$! +for _ in {1..30}; do curl --noproxy '*' -fsS "http://127.0.0.1:$DEBUG_PORT/json/list" >/dev/null 2>&1 && break; sleep 1; done +curl --noproxy '*' -fsS "http://127.0.0.1:$DEBUG_PORT/json/list" >/dev/null +CHROME_DEBUG_URL="http://127.0.0.1:$DEBUG_PORT" node "$ROOT/scripts/smoke-web-browser.mjs" "http://127.0.0.1:$PORT" "$TMP" +chromium --headless --no-sandbox --disable-gpu --screenshot="$TMP/public-mobile.png" --window-size=390,844 "http://127.0.0.1:$PORT/login" >/dev/null 2>&1 +test -s "$TMP/public-en.png" && test -s "$TMP/public-mobile.png" && test -s "$TMP/admin-dashboard.png" && test -s "$TMP/admin-settings.png" +echo "interactive web browser smoke passed (set KEEP_SMOKE_ARTIFACTS=1 to retain temporary screenshots)"