From 4b78f30a617c0035a80905c63cf884b555d5bfaa Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 17 Jul 2026 05:44:27 +0800 Subject: [PATCH] feat(web): add embedded localized sync console --- README.md | 34 ++ internal/server/config.go | 16 + internal/server/handlers_admin.go | 20 +- internal/server/handlers_api.go | 5 +- internal/server/handlers_web_user.go | 408 ++++++++---------- internal/server/locale.go | 197 ++++++++- internal/server/routes.go | 23 +- internal/server/server.go | 14 +- internal/server/web/static/app.css | 51 +++ internal/server/web/static/app.js | 7 + internal/server/web/static/logo.svg | 1 + internal/server/web/templates/admin.html | 27 ++ .../web/templates/admin_create_user.html | 2 + .../server/web/templates/admin_login.html | 2 + internal/server/web/templates/dashboard.html | 2 + internal/server/web/templates/error.html | 2 + internal/server/web/templates/forgot.html | 5 + internal/server/web/templates/home.html | 10 + internal/server/web/templates/layout.html | 35 ++ internal/server/web/templates/login.html | 7 + internal/server/web/templates/message.html | 2 + internal/server/web/templates/register.html | 7 + internal/server/web/templates/reset.html | 6 + internal/server/web_admin.go | 394 +++++++++++++++++ internal/server/web_locale.go | 71 +++ internal/server/web_locale_test.go | 179 ++++++++ internal/server/web_render.go | 228 ++++++++++ 27 files changed, 1497 insertions(+), 258 deletions(-) create mode 100644 internal/server/web/static/app.css create mode 100644 internal/server/web/static/app.js create mode 100644 internal/server/web/static/logo.svg create mode 100644 internal/server/web/templates/admin.html create mode 100644 internal/server/web/templates/admin_create_user.html create mode 100644 internal/server/web/templates/admin_login.html create mode 100644 internal/server/web/templates/dashboard.html create mode 100644 internal/server/web/templates/error.html create mode 100644 internal/server/web/templates/forgot.html create mode 100644 internal/server/web/templates/home.html create mode 100644 internal/server/web/templates/layout.html create mode 100644 internal/server/web/templates/login.html create mode 100644 internal/server/web/templates/message.html create mode 100644 internal/server/web/templates/register.html create mode 100644 internal/server/web/templates/reset.html create mode 100644 internal/server/web_admin.go create mode 100644 internal/server/web_locale.go create mode 100644 internal/server/web_locale_test.go create mode 100644 internal/server/web_render.go diff --git a/README.md b/README.md index c16f8a8..9ae491a 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,13 @@ retention: idempotency_hours: 24 audit_days: 90 temp_upload_hours: 24 +web: + # Server default; visitors may choose System, Русский, or English in a cookie. + default_locale: en + # Set false for invite/admin-only installations. + allow_registration: true + # Product name rendered in the embedded web console. + server_name: Verstak Sync Server ``` Production installs use: @@ -217,6 +224,33 @@ Operational endpoints: - `/admin/...` - Admin web UI and admin JSON endpoints - `/register`, `/login`, `/dashboard`, `/forgot`, `/reset`, `/logout` - User web UI +## Embedded web console + +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. + +`/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 +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. + +All browser mutations use POST and validate a server-side session plus CSRF +token. 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. + 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/config.go b/internal/server/config.go index 0140988..34f4e54 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -26,6 +26,7 @@ type Config struct { TrustedProxies []string `yaml:"trusted_proxies,omitempty"` PublicURL string `yaml:"public_url,omitempty"` DevelopmentTokenLogging bool `yaml:"development_token_logging,omitempty"` + Web WebConfig `yaml:"web,omitempty"` Limits Limits `yaml:"limits,omitempty"` Retention Retention `yaml:"retention,omitempty"` Admin []AdminUser `yaml:"admin"` @@ -34,6 +35,14 @@ type Config struct { trustedProxyPrefixes []netip.Prefix } +// WebConfig intentionally contains only presentation policy. It never +// duplicates transport limits or security configuration owned by the server. +type WebConfig struct { + DefaultLocale string `yaml:"default_locale,omitempty"` + AllowRegistration bool `yaml:"allow_registration,omitempty"` + ServerName string `yaml:"server_name,omitempty"` +} + // Retention controls data that has no role in reconstructing a vault. Sync // operations and referenced blobs are deliberately absent: pruning either // requires a checkpoint protocol or risks making a new device unrecoverable. @@ -73,6 +82,7 @@ func DefaultConfig() *Config { return &Config{ Port: 47732, Listen: "127.0.0.1:47732", + Web: WebConfig{DefaultLocale: "en", AllowRegistration: true, ServerName: "Verstak Sync Server"}, Limits: defaultLimits(), Retention: Retention{IdempotencyHours: 24, AuditDays: 90, TempUploadHours: 24}, } @@ -151,6 +161,12 @@ func (c *Config) normalize() error { if c.Retention.TempUploadHours <= 0 { c.Retention.TempUploadHours = 24 } + if !isSupportedWebLocale(c.Web.DefaultLocale) { + c.Web.DefaultLocale = "en" + } + if strings.TrimSpace(c.Web.ServerName) == "" { + c.Web.ServerName = "Verstak Sync Server" + } prefixes := make([]netip.Prefix, 0, len(c.TrustedProxies)) for _, raw := range c.TrustedProxies { raw = strings.TrimSpace(raw) diff --git a/internal/server/handlers_admin.go b/internal/server/handlers_admin.go index 92873f2..0ad7d80 100644 --- a/internal/server/handlers_admin.go +++ b/internal/server/handlers_admin.go @@ -17,20 +17,10 @@ import ( func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(`Admin Login - -

Admin Login

- - -
`)) + s.renderPage(w, r, "admin_login", webPage{Title: "admin.loginTitle", Admin: true}) case "POST": if err := r.ParseForm(); err != nil { - http.Error(w, "bad form", 400) + s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/login") return } user := r.FormValue("username") @@ -39,7 +29,7 @@ button{background:#4ecca3;color:#1a1a2e;border:none;padding:0.5rem 1rem;border-r return } if !s.cfg.CheckAdmin(user, pass) { - http.Error(w, "401 Unauthorized", 401) + s.renderPageStatus(w, r, "admin_login", webPage{Title: "admin.loginTitle", Admin: true, Flash: "error.invalidCredentials"}, http.StatusUnauthorized) return } tok, csrf, err := s.createSession(sessionScopeAdmin, user) @@ -48,9 +38,9 @@ button{background:#4ecca3;color:#1a1a2e;border:none;padding:0.5rem 1rem;border-r return } s.setSessionCookies(w, r, sessionScopeAdmin, tok, csrf) - http.Redirect(w, r, "/admin/dashboard", http.StatusFound) + http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther) default: - http.Error(w, "method not allowed", 405) + methodNotAllowed(w, http.MethodGet, http.MethodPost) } } diff --git a/internal/server/handlers_api.go b/internal/server/handlers_api.go index c54d8d7..01c65fd 100644 --- a/internal/server/handlers_api.go +++ b/internal/server/handlers_api.go @@ -14,9 +14,8 @@ import ( ) func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/" { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Write([]byte("Verstak Sync Server\n")) + 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) return } jsonErr(w, 404, "not found") diff --git a/internal/server/handlers_web_user.go b/internal/server/handlers_web_user.go index 47c5e9e..4466df6 100644 --- a/internal/server/handlers_web_user.go +++ b/internal/server/handlers_web_user.go @@ -4,11 +4,8 @@ import ( "crypto/rand" "encoding/hex" "fmt" - "html" "log" "net/http" - "net/url" - "strconv" "strings" "time" @@ -23,316 +20,258 @@ func (s *Server) requireUserWeb(w http.ResponseWriter, r *http.Request) (string, return session.SubjectID, true } +func (s *Server) renderWebError(w http.ResponseWriter, r *http.Request, status int, message, back string) { + s.renderPageStatus(w, r, "error", webPage{Title: "error.label", Heading: "error.badRequest", Message: message, BackURL: back}, status) +} + func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) { - locale := s.locale() + if !s.cfg.Web.AllowRegistration { + s.renderWebError(w, r, http.StatusNotFound, "error.registrationDisabled", "/login") + return + } switch r.Method { - case "GET": - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(userRegisterHTML(locale))) - case "POST": + case http.MethodGet: + s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle"}) + case http.MethodPost: if err := r.ParseForm(); err != nil { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(400) - w.Write([]byte(errorPageHTML(locale, "400 Bad request", "400 Bad request", "/register"))) + s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/register") return } - username := r.FormValue("username") - email := r.FormValue("email") - password := r.FormValue("password") + username, email, password := strings.TrimSpace(r.FormValue("username")), strings.TrimSpace(r.FormValue("email")), r.FormValue("password") if username == "" || email == "" || password == "" { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(400) - w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.allFieldsRequired"), "/register"))) + s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle", Flash: "error.allFieldsRequired"}) return } if !s.allowRate(w, r, "register", email) { return } if err := validatePassword(password); err != "" { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(400) - w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), string(err), "/register"))) + s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle", Flash: "error.passwordInvalid"}) return } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(500) - w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), "internal error", "/register"))) + log.Printf("web register: password hashing: %v", err) + s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/register") return } - now := time.Now().UTC().Format(time.RFC3339) id := make([]byte, 12) - rand.Read(id) + if _, err := rand.Read(id); err != nil { + s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/register") + return + } userID := hex.EncodeToString(id) - _, err = s.db.Exec( - "INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)", - userID, username, strings.ToLower(email), string(hash), now, - ) - if err != nil { - w.Header().Set("Content-Type", "text/html; charset=utf-8") + now := time.Now().UTC().Format(time.RFC3339) + if _, err := s.db.Exec("INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)", userID, username, strings.ToLower(email), string(hash), now); err != nil { if strings.Contains(err.Error(), "UNIQUE") { - w.WriteHeader(409) - w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), "Username or email already taken", "/register"))) - } else { - log.Printf("register web: create user failed: %v", err) - w.WriteHeader(500) - w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.registrationFailed"), "/register"))) + s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle", Flash: "error.accountTaken"}) + return } + log.Printf("web register: create user: %v", err) + s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/register") return } - tokenStr, err := issueEmailToken(s.db, userID, "confirm", 48*time.Hour) + token, err := issueEmailToken(s.db, userID, "confirm", 48*time.Hour) if err != nil { - jsonInternalError(w, err) + log.Printf("web register: issue confirmation token: %v", err) + s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/register") return } - host := s.smtpGet("smtp_host") - if host != "" { - srvURL := s.smtpGet("server_url") - var confirmURL string - if srvURL != "" { - confirmURL = fmt.Sprintf("%s/api/v1/auth/confirm?token=%s", srvURL, tokenStr) - } else { - confirmURL = fmt.Sprintf("http://%s/api/v1/auth/confirm?token=%s", r.Host, tokenStr) + if host := s.smtpGet("smtp_host"); host != "" { + base := s.smtpGet("server_url") + if base == "" { + base = "http://" + r.Host } - body := fmt.Sprintf(t(locale, "server.emailConfirmBody"), confirmURL) - if err := s.smtpSend(email, t(locale, "server.emailConfirmSubject"), body); err != nil { - log.Printf("register web: failed to send confirm email: %v", err) + confirmURL := fmt.Sprintf("%s/api/v1/auth/confirm?token=%s", strings.TrimRight(base, "/"), token) + if err := s.smtpSend(email, t(s.webLocale(r), "server.emailConfirmSubject"), fmt.Sprintf(t(s.webLocale(r), "server.emailConfirmBody"), confirmURL)); err != nil { + log.Printf("web register: confirmation mail: %v", err) } } else if s.cfg.DevelopmentTokenLogging { - log.Printf("development confirmation token for user %s: %s", username, tokenStr) + log.Printf("development confirmation token for user %s: %s", username, token) } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(registrationOKHTML(locale))) + http.Redirect(w, r, "/register/result", http.StatusSeeOther) default: - jsonErr(w, 405, "method not allowed") + methodNotAllowed(w, http.MethodGet, http.MethodPost) } } func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) { - locale := s.locale() switch r.Method { - case "GET": - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(forgotPasswordHTML(locale))) - case "POST": + case http.MethodGet: + s.renderPage(w, r, "forgot", webPage{Title: "auth.forgotTitle"}) + case http.MethodPost: if err := r.ParseForm(); err != nil { - jsonErr(w, 400, "bad form") + s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/forgot") return } - email := strings.ToLower(r.FormValue("email")) + email := strings.ToLower(strings.TrimSpace(r.FormValue("email"))) if email == "" { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.needEmail"), "/forgot"))) + s.renderPage(w, r, "forgot", webPage{Title: "auth.forgotTitle", Flash: "error.emailRequired"}) return } if !s.allowRate(w, r, "forgot", email) { return } var userID string - err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", email).Scan(&userID) - if err != nil { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(forgotSentHTML(locale))) - return - } - tokenStr, err := issueEmailToken(s.db, userID, "reset", time.Hour) - if err != nil { - jsonInternalError(w, err) - return - } - host := s.smtpGet("smtp_host") - if host != "" { - srvURL := s.smtpGet("server_url") - resetURL := fmt.Sprintf("/reset?token=%s", tokenStr) - if srvURL != "" { - resetURL = fmt.Sprintf("%s/reset?token=%s", srvURL, tokenStr) + if err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", email).Scan(&userID); err == nil { + token, issueErr := issueEmailToken(s.db, userID, "reset", time.Hour) + if issueErr != nil { + log.Printf("web forgot: issue reset token: %v", issueErr) + s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/forgot") + return } - body := fmt.Sprintf(t(locale, "server.emailResetBody"), resetURL) - if err := s.smtpSend(email, t(locale, "server.emailResetSubject"), body); err != nil { - log.Printf("forgot web: failed to send reset email: %v", err) + if s.smtpGet("smtp_host") != "" { + base := s.smtpGet("server_url") + if base == "" { + base = "http://" + r.Host + } + resetURL := fmt.Sprintf("%s/reset?token=%s", strings.TrimRight(base, "/"), token) + if err := s.smtpSend(email, t(s.webLocale(r), "server.emailResetSubject"), fmt.Sprintf(t(s.webLocale(r), "server.emailResetBody"), resetURL)); err != nil { + log.Printf("web forgot: reset mail: %v", err) + } + } else if s.cfg.DevelopmentTokenLogging { + log.Printf("development reset token requested for %s: %s", email, token) } - } else if s.cfg.DevelopmentTokenLogging { - log.Printf("development reset token requested for %s: %s", email, tokenStr) } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(forgotSentHTML(locale))) + http.Redirect(w, r, "/forgot/sent", http.StatusSeeOther) default: - jsonErr(w, 405, "method not allowed") + methodNotAllowed(w, http.MethodGet, http.MethodPost) } } +func (s *Server) validResetToken(token string) bool { + var expiresAt string + if err := s.db.QueryRow("SELECT expires_at FROM server_email_tokens WHERE token_hash=? AND purpose='reset'", emailTokenHash(token)).Scan(&expiresAt); err != nil { + return false + } + expires, err := time.Parse(time.RFC3339, expiresAt) + return err == nil && time.Now().Before(expires) +} + func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) { - locale := s.locale() + w.Header().Set("Cache-Control", "no-store") switch r.Method { - case "GET": + case http.MethodGet: token := r.URL.Query().Get("token") - if token == "" { + if token == "" || !s.validResetToken(token) { http.Redirect(w, r, "/forgot", http.StatusFound) return } - var userID, expiresAt string - err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token_hash=? AND purpose='reset'", - emailTokenHash(token)).Scan(&userID, &expiresAt) - if err != nil { - http.Redirect(w, r, "/forgot", http.StatusFound) - return - } - exp, err := time.Parse(time.RFC3339, expiresAt) - if err != nil || time.Now().After(exp) { - http.Redirect(w, r, "/forgot", http.StatusFound) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - page := strings.ReplaceAll(resetPasswordHTML(locale), "{TOKEN}", html.EscapeString(token)) - w.Write([]byte(page)) - case "POST": + s.renderPage(w, r, "reset", webPage{Title: "auth.resetTitle", Token: token}) + case http.MethodPost: if err := r.ParseForm(); err != nil { - jsonErr(w, 400, "bad form") + s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/forgot") return } - token := r.FormValue("token") - newPass := r.FormValue("password") - confirm := r.FormValue("confirm") - if token == "" || newPass == "" || confirm == "" { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.allFieldsRequired"), "/forgot"))) + 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", "") { return } - if err := validatePassword(newPass); err != "" { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), string(err), "/reset?token="+url.QueryEscape(token)))) + if err := validatePassword(password); err != "" { + s.renderPage(w, r, "reset", webPage{Title: "auth.resetTitle", Token: token, Flash: "error.passwordInvalid"}) return } - if newPass != confirm { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.passwordsDoNotMatch"), "/reset?token="+url.QueryEscape(token)))) + if password != confirm { + s.renderPage(w, r, "reset", webPage{Title: "auth.resetTitle", Token: token, Flash: "error.passwordMismatch"}) return } - userID, err := s.resetPasswordWithToken(token, newPass) + userID, err := s.resetPasswordWithToken(token, password) if err == errResetTokenInvalid || err == errResetTokenExpired { http.Redirect(w, r, "/forgot", http.StatusFound) return } if err != nil { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "common.error"), "/forgot"))) + log.Printf("web reset: %v", err) + s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/forgot") return } log.Printf("reset: user %s reset password", userID) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(resetDoneHTML(locale))) + http.Redirect(w, r, "/reset/done", http.StatusSeeOther) default: - jsonErr(w, 405, "method not allowed") + methodNotAllowed(w, http.MethodGet, http.MethodPost) } } func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) { - locale := s.locale() switch r.Method { - case "GET": - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(userLoginHTML(locale))) - case "POST": + case http.MethodGet: + s.renderPage(w, r, "login", webPage{Title: "auth.loginTitle"}) + case http.MethodPost: if err := r.ParseForm(); err != nil { - jsonErr(w, 400, "bad form") + s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login") return } - username := r.FormValue("username") - password := r.FormValue("password") - if !s.allowRate(w, r, "login", username) { + login, password := strings.TrimSpace(r.FormValue("username")), r.FormValue("password") + if !s.allowRate(w, r, "login", login) { return } var userID, hash string var confirmed, blocked int - err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?", - username, strings.ToLower(username)).Scan(&userID, &hash, &confirmed, &blocked) + err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?", login, strings.ToLower(login)).Scan(&userID, &hash, &confirmed, &blocked) if err != nil || blocked != 0 || confirmed == 0 || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(401) - w.Write([]byte(errorPageHTML(locale, "401 Unauthorized", "401 Unauthorized", "/login"))) + s.renderPageStatus(w, r, "login", webPage{Title: "auth.loginTitle", Flash: "error.invalidCredentials"}, http.StatusUnauthorized) return } - tok, csrf, err := s.createSession(sessionScopeUser, userID) + token, csrf, err := s.createSession(sessionScopeUser, userID) if err != nil { - jsonInternalError(w, err) + log.Printf("web login: session: %v", err) + s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/login") return } - s.setSessionCookies(w, r, sessionScopeUser, tok, csrf) - http.Redirect(w, r, "/dashboard", http.StatusFound) + s.setSessionCookies(w, r, sessionScopeUser, token, csrf) + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) default: - jsonErr(w, 405, "method not allowed") + methodNotAllowed(w, http.MethodGet, http.MethodPost) } } func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) { - locale := s.locale() + if r.Method != http.MethodGet { + methodNotAllowed(w, http.MethodGet) + return + } userID, ok := s.requireUserWeb(w, r) if !ok { return } - var username string - s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username) - - type dev struct { - ID, Name, LastSeen, CreatedAt, ClientVer, RevokedAt string + var username, email string + if err := s.db.QueryRow("SELECT username, email FROM server_users WHERE id=?", userID).Scan(&username, &email); err != nil { + s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/") + return } - var devices []dev - rows, err := s.db.Query(` - SELECT d.id, d.name, COALESCE(d.last_seen,''), d.created_at, - COALESCE(d.client_version,''), COALESCE(d.revoked_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) - if err == nil { - defer rows.Close() - for rows.Next() { - var d dev - rows.Scan(&d.ID, &d.Name, &d.LastSeen, &d.CreatedAt, &d.ClientVer, &d.RevokedAt) - devices = append(devices, d) + 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) + if err != nil { + s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/") + return + } + defer rows.Close() + var devices []webDevice + for rows.Next() { + var d webDevice + var revoked string + if err := rows.Scan(&d.ID, &d.Name, &d.Vault, &d.ClientVersion, &d.LastSeen, &revoked, &d.CreatedAt); err != nil { + s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/") + return } - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - deviceRows := "" - if len(devices) == 0 { - deviceRows = "" + t(locale, "userDashboard.noDevices") + "" - } else { - for _, d := range devices { - ls := d.LastSeen - if ls == "" { - ls = "—" - } - created := d.CreatedAt - if len(created) > 10 { - created = created[:10] - } - status := "" + t(locale, "userDashboard.active") + "" - revokeBtn := fmt.Sprintf(``, html.EscapeString(strconv.Quote(d.ID)), t(locale, "userDashboard.revoke")) - if d.RevokedAt != "" { - status = "" + t(locale, "userDashboard.revoked") + "" - revokeBtn = "" - } - deviceRows += fmt.Sprintf(` - %s - %s - %s - %s - %s %s - `, html.EscapeString(d.Name), status, html.EscapeString(created), html.EscapeString(ls), html.EscapeString(d.ClientVer), revokeBtn) + d.Revoked = revoked != "" + if d.LastSeen == "" { + d.LastSeen = "—" } + devices = append(devices, d) } - - csrf := "" - if cookie, err := r.Cookie("csrf_token"); err == nil { - csrf = cookie.Value + if err := rows.Err(); err != nil { + s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/") + return } - w.Write([]byte(userDashboardHTML(locale, html.EscapeString(username), deviceRows, csrf))) + flash := r.URL.Query().Get("error") + if flash != "error.invalidCredentials" { + flash = "" + } + s.renderPage(w, r, "dashboard", webPage{Title: "user.account", UserName: username, Email: email, Devices: devices, Flash: flash}) } func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) { @@ -362,38 +301,30 @@ func (s *Server) handleUserDevices(w http.ResponseWriter, r *http.Request) { if !ok { return } - - rows, err := s.db.Query(` - SELECT d.id, d.name, 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) + rows, err := s.db.Query(`SELECT d.id,d.name,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) if err != nil { jsonInternalError(w, err) return } defer rows.Close() - - type devDTO struct { - ID string `json:"id"` - Name string `json:"name"` - ClientVersion string `json:"client_version"` - LastSeen string `json:"last_seen"` - RevokedAt string `json:"revoked_at"` - CreatedAt string `json:"created_at"` - } - var devices []devDTO + var devices []map[string]string for rows.Next() { - var d devDTO - rows.Scan(&d.ID, &d.Name, &d.ClientVersion, &d.LastSeen, &d.RevokedAt, &d.CreatedAt) - devices = append(devices, d) + var id, name, version, lastSeen, revoked, created string + if err := rows.Scan(&id, &name, &version, &lastSeen, &revoked, &created); err != nil { + jsonInternalError(w, err) + return + } + devices = append(devices, map[string]string{"id": id, "name": name, "client_version": version, "last_seen": lastSeen, "revoked_at": revoked, "created_at": created}) + } + if err := rows.Err(); err != nil { + jsonInternalError(w, err) + return } jsonOK(w, devices) } -// handleUserWebDeviceAction is deliberately session/CSRF based. Browser UI -// never receives a desktop device bearer token. +// handleUserWebDeviceAction accepts the dashboard's regular form as well as +// the existing JSON API. Both paths are session and CSRF protected. func (s *Server) handleUserWebDeviceAction(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { methodNotAllowed(w, http.MethodPost) @@ -409,14 +340,21 @@ func (s *Server) handleUserWebDeviceAction(w http.ResponseWriter, r *http.Reques return } deviceID := strings.TrimSuffix(strings.TrimSuffix(path, "/revoke"), "/") - var req struct { - Password string `json:"password"` + password := "" + formRequest := strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") + if formRequest { + password = r.FormValue("password") + } else { + var req struct { + Password string `json:"password"` + } + if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) { + return + } + password = req.Password } - if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) { - return - } - if req.Password == "" || !s.allowRate(w, r, "auth-test", session.SubjectID) { - if req.Password == "" { + if password == "" || !s.allowRate(w, r, "auth-test", session.SubjectID) { + if password == "" { jsonErrCode(w, http.StatusBadRequest, "invalid_request", "password required") } return @@ -426,8 +364,12 @@ func (s *Server) handleUserWebDeviceAction(w http.ResponseWriter, r *http.Reques jsonInternalError(w, err) return } - if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil { - jsonErr(w, http.StatusForbidden, "wrong password") + if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { + if formRequest { + http.Redirect(w, r, "/dashboard?error=error.invalidCredentials", http.StatusSeeOther) + } else { + jsonErr(w, http.StatusForbidden, "wrong password") + } return } var owner string @@ -444,5 +386,9 @@ func (s *Server) handleUserWebDeviceAction(w http.ResponseWriter, r *http.Reques return } s.auditLog("device_revoked", session.SubjectID, deviceID, s.clientIP(r), "device revoked from web dashboard") + if formRequest { + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + return + } jsonOK(w, map[string]string{"status": "revoked"}) } diff --git a/internal/server/locale.go b/internal/server/locale.go index 6c68222..962cd84 100644 --- a/internal/server/locale.go +++ b/internal/server/locale.go @@ -6,16 +6,114 @@ func t(locale, key string) string { return v } } - if translations, ok := _translations["ru"]; ok { + if translations, ok := _translations["en"]; ok { if v, ok := translations[key]; ok { return v } } - return key + if locale == "ru" { + return "Перевод временно недоступен" + } + return "Translation unavailable" } var _translations = map[string]map[string]string{ "ru": { + "home.title": "Синхронизация Верстака", + "home.eyebrow": "Локальная работа, когда нужно", + "home.heading": "Ваши vault остаются вашими", + "home.description": "Verstak Sync Server безопасно связывает ваши устройства, не заменяя локальный vault.", + "home.login": "Войти", + "home.register": "Создать аккаунт", + "home.version": "Версия", + "nav.primary": "Основная навигация", + "nav.login": "Войти", + "nav.register": "Регистрация", + "footer.localFirst": "Локальная работа прежде всего", + "locale.label": "Язык", + "locale.system": "Системный", + "locale.apply": "Применить язык", + "auth.welcome": "Добро пожаловать", + "auth.account": "Учётная запись", + "auth.loginTitle": "Вход в синхронизацию", + "auth.login": "Войти", + "auth.logout": "Выйти", + "auth.register": "Создать аккаунт", + "auth.registerTitle": "Создать аккаунт", + "auth.haveAccount": "Уже есть аккаунт? Войти", + "auth.forgot": "Забыли пароль?", + "auth.forgotTitle": "Восстановление пароля", + "auth.forgotDescription": "Укажите email, и мы отправим ссылку для сброса пароля, если аккаунт существует.", + "auth.sendLink": "Отправить ссылку", + "auth.backLogin": "Вернуться ко входу", + "auth.resetTitle": "Новый пароль", + "auth.savePassword": "Сохранить пароль", + "field.username": "Имя пользователя", + "field.usernameOrEmail": "Имя пользователя или email", + "field.email": "Email", + "field.password": "Пароль", + "field.newPassword": "Новый пароль", + "field.confirmPassword": "Подтвердите пароль", + "register.resultTitle": "Проверьте почту", + "register.resultMessage": "Если SMTP настроен, мы отправили ссылку подтверждения. После подтверждения можно войти.", + "forgot.sentTitle": "Проверьте почту", + "forgot.sentMessage": "Если аккаунт существует, ссылка для сброса уже отправлена.", + "reset.doneTitle": "Пароль изменён", + "reset.doneMessage": "Теперь можно войти с новым паролем.", + "common.continue": "Продолжить", + "common.back": "Назад", + "common.actions": "Действия", + "error.label": "Ошибка", + "error.badRequest": "Некорректный запрос", + "error.tryAgain": "Проверьте данные и повторите попытку.", + "error.registrationDisabled": "Регистрация отключена администратором.", + "error.allFieldsRequired": "Все поля обязательны.", + "error.passwordInvalid": "Пароль не соответствует требованиям безопасности.", + "error.accountTaken": "Имя пользователя или email уже заняты.", + "error.emailRequired": "Введите email.", + "error.passwordMismatch": "Пароли не совпадают.", + "error.invalidCredentials": "Неверное имя пользователя или пароль.", + "error.internal": "Внутренняя ошибка. Повторите попытку позже.", + "admin.eyebrow": "Администрирование сервера", + "admin.loginTitle": "Вход администратора", + "admin.navigation": "Навигация администратора", + "admin.overview": "Обзор", + "admin.access": "Доступ", + "admin.activeDevices": "Активные устройства", + "admin.operations": "Операции", + "admin.vaults": "Vault", + "admin.storage": "Хранилище", + "admin.audit": "Аудит", + "admin.settings": "Настройки", + "admin.diagnostics": "Диагностика", + "admin.serviceHealth": "Состояние сервиса", + "admin.lastActivity": "Последняя активность", + "admin.noVaults": "Vault пока нет", + "admin.databaseBytes": "Размер базы данных, байт", + "admin.blobBytes": "Объём blobs, байт", + "admin.retentionNote": "Журнал операций не удаляется автоматически: он нужен новым устройствам для восстановления состояния.", + "admin.event": "Событие", + "admin.time": "Время", + "admin.noAudit": "Событий аудита пока нет", + "admin.database": "База данных доступна", + "admin.blobStorage": "Blob-хранилище доступно", + "admin.schema": "Версия схемы", + "admin.serverTime": "Время сервера", + "admin.healthJSON": "Открыть health JSON", + "admin.manage": "Управление", + "admin.saveUser": "Сохранить пользователя", + "user.account": "Моя учётная запись", + "user.devices": "Подключённые устройства", + "user.noDevices": "Устройств пока нет", + "device.name": "Устройство", + "device.vault": "Vault", + "device.version": "Версия клиента", + "device.lastSeen": "Последняя активность", + "device.status": "Статус", + "device.active": "Активно", + "device.revoked": "Отозвано", + "device.revoke": "Отозвать", + "device.revokeConfirm": "Отозвать это устройство? Для продолжения нужен пароль.", "server.registerTitle": "Регистрация", "server.register": "Регистрация", "server.username": "Имя пользователя", @@ -146,6 +244,101 @@ var _translations = map[string]map[string]string{ "admin.createUserFailed": "Не удалось создать пользователя. Повторите попытку.", }, "en": { + "home.title": "Verstak Sync", + "home.eyebrow": "Local work, when it matters", + "home.heading": "Your vaults stay yours", + "home.description": "Verstak Sync Server securely connects your devices without replacing the local vault.", + "home.login": "Login", + "home.register": "Create account", + "home.version": "Version", + "nav.primary": "Primary navigation", + "nav.login": "Login", + "nav.register": "Register", + "footer.localFirst": "Local-first by design", + "locale.label": "Language", + "locale.system": "System", + "locale.apply": "Apply language", + "auth.welcome": "Welcome", + "auth.account": "Account", + "auth.loginTitle": "Sign in to sync", + "auth.login": "Login", + "auth.logout": "Logout", + "auth.register": "Create account", + "auth.registerTitle": "Create an account", + "auth.haveAccount": "Already have an account? Login", + "auth.forgot": "Forgot password?", + "auth.forgotTitle": "Reset your password", + "auth.forgotDescription": "Enter your email and we will send a reset link if the account exists.", + "auth.sendLink": "Send reset link", + "auth.backLogin": "Back to login", + "auth.resetTitle": "New password", + "auth.savePassword": "Save password", + "field.username": "Username", + "field.usernameOrEmail": "Username or email", + "field.email": "Email", + "field.password": "Password", + "field.newPassword": "New password", + "field.confirmPassword": "Confirm password", + "register.resultTitle": "Check your email", + "register.resultMessage": "If SMTP is configured, we sent a confirmation link. You can sign in after confirmation.", + "forgot.sentTitle": "Check your email", + "forgot.sentMessage": "If the account exists, a reset link has been sent.", + "reset.doneTitle": "Password changed", + "reset.doneMessage": "You can now sign in with your new password.", + "common.continue": "Continue", + "common.back": "Back", + "common.actions": "Actions", + "error.label": "Error", + "error.badRequest": "Invalid request", + "error.tryAgain": "Check the entered data and try again.", + "error.registrationDisabled": "Registration is disabled by the administrator.", + "error.allFieldsRequired": "All fields are required.", + "error.passwordInvalid": "The password does not meet the security requirements.", + "error.accountTaken": "Username or email is already in use.", + "error.emailRequired": "Enter an email address.", + "error.passwordMismatch": "Passwords do not match.", + "error.invalidCredentials": "Invalid username or password.", + "error.internal": "Internal error. Please try again later.", + "admin.eyebrow": "Server administration", + "admin.loginTitle": "Administrator login", + "admin.navigation": "Administrator navigation", + "admin.overview": "Overview", + "admin.access": "Access", + "admin.activeDevices": "Active devices", + "admin.operations": "Operations", + "admin.vaults": "Vaults", + "admin.storage": "Storage", + "admin.audit": "Audit log", + "admin.settings": "Settings", + "admin.diagnostics": "Diagnostics", + "admin.serviceHealth": "Service health", + "admin.lastActivity": "Last activity", + "admin.noVaults": "No vaults yet", + "admin.databaseBytes": "Database size, bytes", + "admin.blobBytes": "Blob storage, bytes", + "admin.retentionNote": "Operations are not deleted automatically: new devices need them to restore state.", + "admin.event": "Event", + "admin.time": "Time", + "admin.noAudit": "No audit events yet", + "admin.database": "Database reachable", + "admin.blobStorage": "Blob storage writable", + "admin.schema": "Schema version", + "admin.serverTime": "Server time", + "admin.healthJSON": "Open health JSON", + "admin.manage": "Manage", + "admin.saveUser": "Save user", + "user.account": "My account", + "user.devices": "Connected devices", + "user.noDevices": "No devices yet", + "device.name": "Device", + "device.vault": "Vault", + "device.version": "Client version", + "device.lastSeen": "Last activity", + "device.status": "Status", + "device.active": "Active", + "device.revoked": "Revoked", + "device.revoke": "Revoke", + "device.revokeConfirm": "Revoke this device? Your password is required to continue.", "server.registerTitle": "Registration", "server.register": "Register", "server.username": "Username", diff --git a/internal/server/routes.go b/internal/server/routes.go index 77a077d..eb05e63 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -1,6 +1,12 @@ package server func (s *Server) routes() { + s.mux.HandleFunc("/static/", s.handleStatic) + s.mux.HandleFunc("/locale", s.handleLocale) + s.mux.HandleFunc("/register/result", s.handleRegistrationResult) + s.mux.HandleFunc("/forgot/sent", s.handleForgotSent) + s.mux.HandleFunc("/reset/done", s.handleResetDone) + s.mux.HandleFunc("/", s.handleHome) s.mux.HandleFunc("/api/v1/health", s.handleHealth) s.mux.HandleFunc("/livez", s.handleLiveness) s.mux.HandleFunc("/readyz", s.handleHealth) @@ -27,11 +33,19 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/v1/user/devices", s.handleUserDevices) s.mux.HandleFunc("/api/v1/user/devices/", s.handleUserWebDeviceAction) s.mux.HandleFunc("/admin/login", s.handleAdminLogin) - s.mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard) - s.mux.HandleFunc("/admin/users", s.handleAdminUsers) - s.mux.HandleFunc("/admin/create-user", s.handleAdminCreateUser) + s.mux.HandleFunc("/admin", s.handleAdminRoot) + s.mux.HandleFunc("/admin/logout", s.handleAdminWebLogout) + s.mux.HandleFunc("/admin/action", s.handleAdminWebAction) + s.mux.HandleFunc("/admin/dashboard", s.handleAdminWeb) + s.mux.HandleFunc("/admin/users", s.handleAdminWeb) + s.mux.HandleFunc("/admin/create-user", s.handleAdminCreateUserWeb) s.mux.HandleFunc("/admin/api/users/create", s.handleAdminAPICreateUser) - s.mux.HandleFunc("/admin/devices", s.handleAdminDevices) + s.mux.HandleFunc("/admin/devices", s.handleAdminWeb) + s.mux.HandleFunc("/admin/vaults", s.handleAdminWeb) + s.mux.HandleFunc("/admin/storage", s.handleAdminWeb) + s.mux.HandleFunc("/admin/audit", s.handleAdminWeb) + s.mux.HandleFunc("/admin/settings", s.handleAdminWeb) + s.mux.HandleFunc("/admin/diagnostics", s.handleAdminWeb) s.mux.HandleFunc("/admin/api/stats", s.handleAdminStats) s.mux.HandleFunc("/admin/api/smtp/test", s.handleAdminSMTPTest) s.mux.HandleFunc("/admin/api/smtp", s.handleAdminAPISmtp) @@ -40,5 +54,4 @@ func (s *Server) routes() { s.mux.HandleFunc("/admin/api/keys", s.handleAdminAPIKeys) s.mux.HandleFunc("/admin/api/users/", s.handleAdminAPIUserActions) s.mux.HandleFunc("/admin/api/users", s.handleAdminAPIUsers) - s.mux.HandleFunc("/", s.handleNotFound) } diff --git a/internal/server/server.go b/internal/server/server.go index d7f3863..a25b99d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -20,6 +20,7 @@ type Server struct { blobsDir string mux *http.ServeMux limiter *rateLimiter + web *webRenderer startedAt time.Time } @@ -79,12 +80,18 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) { return nil, err } + web, err := newWebRenderer() + if err != nil { + db.Close() + 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(), } s.mux = http.NewServeMux() @@ -96,7 +103,10 @@ func (s *Server) SetupRoutes() { } func (s *Server) locale() string { - return "ru" + if s != nil && s.cfg != nil && isSupportedWebLocale(s.cfg.Web.DefaultLocale) { + return s.cfg.Web.DefaultLocale + } + return "en" } func (s *Server) Close() error { @@ -106,7 +116,7 @@ func (s *Server) Close() error { // Handler is the only HTTP entrypoint. Additional request security middleware // is composed here so tests and production use the same path. func (s *Server) Handler() http.Handler { - return s.mux + return securityHeaders(s.mux) } // HTTPServer creates a conservatively configured server suitable for running diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css new file mode 100644 index 0000000..56e0da0 --- /dev/null +++ b/internal/server/web/static/app.css @@ -0,0 +1,51 @@ +:root { color-scheme: dark; --bg:#10151a; --surface:#172128; --surface-2:#1e2b33; --line:#2b3b45; --text:#e5eef1; --muted:#9bb0b8; --accent:#4fd1b5; --accent-ink:#08231f; --danger:#f08080; --focus:#8ee7d5; } +* { box-sizing:border-box; } +body { min-width:320px; margin:0; background:var(--bg); color:var(--text); font:15px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; } +.site-header,.site-footer { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:1rem clamp(1rem,4vw,4rem); border-bottom:1px solid var(--line); } +.site-footer { border-top:1px solid var(--line); border-bottom:0; color:var(--muted); font-size:.875rem; } +.brand { display:inline-flex; align-items:center; gap:.65rem; color:var(--text); font-weight:700; text-decoration:none; } +.site-nav { display:flex; gap:.9rem; } +.site-nav a { color:var(--muted); text-decoration:none; } +.site-nav a:hover,.site-nav a:focus-visible { color:var(--accent); } +.locale-form { margin-left:auto; } +.locale-form select { min-height:34px; padding:.3rem .55rem; border:1px solid var(--line); border-radius:7px; background:var(--surface-2); color:var(--text); } +.page-shell { width:min(100% - 2rem,1100px); min-height:calc(100vh - 150px); margin:0 auto; padding:clamp(2rem,6vw,5rem) 0; } +.card { border:1px solid var(--line); border-radius:16px; background:linear-gradient(145deg,var(--surface),#142027); box-shadow:0 14px 40px #0004; } +.hero { max-width:720px; padding:clamp(2rem,7vw,5rem); } +.auth-card,.message-card { width:min(100%,480px); margin:0 auto; padding:2rem; } +.eyebrow { margin:0; color:var(--accent); font-size:.8rem; font-weight:700; letter-spacing:.12em; text-transform:uppercase; } +.danger-text { color:var(--danger); } +.hero h1 { max-width:14ch; margin:.6rem 0; font-size:clamp(2.2rem,6vw,4.6rem); line-height:1.05; } +.lead { max-width:58ch; color:var(--muted); font-size:1.1rem; } +.actions { display:flex; flex-wrap:wrap; gap:.75rem; margin-top:2rem; } +.button { display:inline-flex; align-items:center; justify-content:center; min-height:42px; padding:.6rem 1rem; border:1px solid transparent; border-radius:8px; font:inherit; font-weight:650; text-decoration:none; cursor:pointer; } +.button.primary { background:var(--accent); color:var(--accent-ink); } +.button.secondary { border-color:var(--line); background:transparent; color:var(--text); } +.button.danger { background:#4c2529; color:#ffd9d9; } +.button:hover { filter:brightness(1.08); } +: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; } +.flash { padding:.75rem; border-radius:8px; } +.flash.error { border:1px solid #6e343b; background:#3a2025; color:#ffd9d9; } +.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; } +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; } +.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; } +.admin-nav { display:grid; align-content:start; gap:.35rem; padding:1rem; border:1px solid var(--line); border-radius:12px; background:var(--surface); } +.admin-nav a { padding:.55rem .65rem; border-radius:7px; color:var(--muted); text-decoration:none; }.admin-nav a[aria-current="page"],.admin-nav a:hover { background:var(--surface-2); color:var(--accent); } +.admin-content h1 { margin-top:0; }.panel { padding:1.25rem; }.section-heading { display:flex; align-items:center; justify-content:space-between; gap:1rem; margin-bottom:1rem; } +.stat-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:1rem; margin:1rem 0; }.stat { display:grid; gap:.35rem; padding:1.2rem; }.stat strong { color:var(--accent); font-size:1.8rem; }.stat span { color:var(--muted); } +.details { display:grid; grid-template-columns:minmax(150px,auto) 1fr; gap:.6rem 1rem; }.details dt { color:var(--muted); }.details dd { margin:0; overflow-wrap:anywhere; } +details { margin-top:.6rem; } summary { cursor:pointer; color:var(--accent); }.compact { margin-top:.7rem; }.compact input { min-height:36px; } +.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; } +@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 new file mode 100644 index 0000000..a0bb498 --- /dev/null +++ b/internal/server/web/static/app.js @@ -0,0 +1,7 @@ +document.addEventListener("change", function (event) { + if (event.target.matches("[data-auto-submit]")) event.target.form.requestSubmit(); +}); +document.addEventListener("click", function (event) { + const button = event.target.closest("[data-confirm]"); + if (button && !window.confirm(button.dataset.confirm)) event.preventDefault(); +}); diff --git a/internal/server/web/static/logo.svg b/internal/server/web/static/logo.svg new file mode 100644 index 0000000..38f0fed --- /dev/null +++ b/internal/server/web/static/logo.svg @@ -0,0 +1 @@ + diff --git a/internal/server/web/templates/admin.html b/internal/server/web/templates/admin.html new file mode 100644 index 0000000..0618807 --- /dev/null +++ b/internal/server/web/templates/admin.html @@ -0,0 +1,27 @@ +{{define "admin"}}{{template "layout" .}}{{end}} +{{define "content"}} +
+ +
+ {{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}}
{{t .Locale "admin.healthJSON"}}
{{end}} +
+
+{{end}} diff --git a/internal/server/web/templates/admin_create_user.html b/internal/server/web/templates/admin_create_user.html new file mode 100644 index 0000000..05799dc --- /dev/null +++ b/internal/server/web/templates/admin_create_user.html @@ -0,0 +1,2 @@ +{{define "admin_create_user"}}{{template "layout" .}}{{end}} +{{define "content"}}

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

{{t .Locale "admin.createUser"}}

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

{{t .Locale "common.back"}}

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

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

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

{{if .Flash}}{{end}}
{{end}} diff --git a/internal/server/web/templates/dashboard.html b/internal/server/web/templates/dashboard.html new file mode 100644 index 0000000..c2d448a --- /dev/null +++ b/internal/server/web/templates/dashboard.html @@ -0,0 +1,2 @@ +{{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}} diff --git a/internal/server/web/templates/error.html b/internal/server/web/templates/error.html new file mode 100644 index 0000000..8efbdd6 --- /dev/null +++ b/internal/server/web/templates/error.html @@ -0,0 +1,2 @@ +{{define "error"}}{{template "layout" .}}{{end}} +{{define "content"}}

{{t .Locale "error.label"}}

{{t .Locale .Heading}}

{{t .Locale .Message}}

{{if .BackURL}}{{t .Locale "common.back"}}{{end}}
{{end}} diff --git a/internal/server/web/templates/forgot.html b/internal/server/web/templates/forgot.html new file mode 100644 index 0000000..1fbe341 --- /dev/null +++ b/internal/server/web/templates/forgot.html @@ -0,0 +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"}}

+{{end}} diff --git a/internal/server/web/templates/home.html b/internal/server/web/templates/home.html new file mode 100644 index 0000000..4f26f6c --- /dev/null +++ b/internal/server/web/templates/home.html @@ -0,0 +1,10 @@ +{{define "home"}}{{template "layout" .}}{{end}} +{{define "content"}} +
+

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

+

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

+

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

+ +

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

+
+{{end}} diff --git a/internal/server/web/templates/layout.html b/internal/server/web/templates/layout.html new file mode 100644 index 0000000..0a54bcc --- /dev/null +++ b/internal/server/web/templates/layout.html @@ -0,0 +1,35 @@ +{{define "layout"}} + + + + + + {{t .Locale .Title}} · {{.ServerName}} + + + + + +
{{template "content" .}}
+ + + + +{{end}} diff --git a/internal/server/web/templates/login.html b/internal/server/web/templates/login.html new file mode 100644 index 0000000..91864b5 --- /dev/null +++ b/internal/server/web/templates/login.html @@ -0,0 +1,7 @@ +{{define "login"}}{{template "layout" .}}{{end}} +{{define "content"}} +

{{t .Locale "auth.welcome"}}

{{t .Locale "auth.loginTitle"}}

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

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

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

Verstak Sync

{{t .Locale .Heading}}

{{t .Locale .Message}}

{{if .BackURL}}{{t .Locale "common.continue"}}{{end}}
{{end}} diff --git a/internal/server/web/templates/register.html b/internal/server/web/templates/register.html new file mode 100644 index 0000000..0695654 --- /dev/null +++ b/internal/server/web/templates/register.html @@ -0,0 +1,7 @@ +{{define "register"}}{{template "layout" .}}{{end}} +{{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 new file mode 100644 index 0000000..dd0eef8 --- /dev/null +++ b/internal/server/web/templates/reset.html @@ -0,0 +1,6 @@ +{{define "reset"}}{{template "layout" .}}{{end}} +{{define "content"}} +

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

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

+{{if .Flash}}{{end}} +
+{{end}} diff --git a/internal/server/web_admin.go b/internal/server/web_admin.go new file mode 100644 index 0000000..8b8d23f --- /dev/null +++ b/internal/server/web_admin.go @@ -0,0 +1,394 @@ +package server + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "log" + "net/http" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, http.MethodGet) + return + } + http.Redirect(w, r, "/admin/dashboard", http.StatusFound) +} + +func (s *Server) handleAdminCreateUserWeb(w http.ResponseWriter, r *http.Request) { + if !s.requireAdminCookie(w, r) { + return + } + switch r.Method { + case http.MethodGet: + s.renderPage(w, r, "admin_create_user", webPage{Title: "admin.createUser", Admin: true}) + case http.MethodPost: + if !s.requireAdminMutation(w, r) { + return + } + if err := r.ParseForm(); err != nil { + s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/create-user") + 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, "admin_create_user", webPage{Title: "admin.createUser", Admin: true, Flash: "error.allFieldsRequired"}) + return + } + if err := validatePassword(password); err != "" { + s.renderPage(w, r, "admin_create_user", webPage{Title: "admin.createUser", Admin: true, Flash: "error.passwordInvalid"}) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + jsonInternalError(w, err) + return + } + id := make([]byte, 12) + if _, err := rand.Read(id); err != nil { + jsonInternalError(w, err) + return + } + userID := hex.EncodeToString(id) + if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES (?, ?, ?, ?, 1, ?)", userID, username, strings.ToLower(email), string(hash), time.Now().UTC().Format(time.RFC3339)); err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + s.renderPage(w, r, "admin_create_user", webPage{Title: "admin.createUser", Admin: true, Flash: "error.accountTaken"}) + return + } + jsonInternalError(w, err) + return + } + s.auditLog("user_created", userID, "", s.clientIP(r), "created by administrator") + http.Redirect(w, r, "/admin/users", http.StatusSeeOther) + default: + methodNotAllowed(w, http.MethodGet, http.MethodPost) + } +} + +func (s *Server) handleAdminWeb(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, http.MethodGet) + return + } + if !s.requireAdminCookie(w, r) { + return + } + page := strings.TrimPrefix(r.URL.Path, "/admin/") + if page == "" || page == "admin" { + page = "dashboard" + } + allowed := map[string]bool{"dashboard": true, "users": true, "devices": true, "vaults": true, "storage": true, "audit": true, "settings": true, "diagnostics": true} + if !allowed[page] { + s.handleNotFound(w, r) + return + } + stats, err := s.Stats(r.Context()) + if err != nil { + log.Printf("admin stats: %v", err) + s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/") + return + } + data := webPage{Title: "admin." + page, Admin: true, AdminPage: page, Stats: stats, Health: s.healthStatus(r.Context())} + switch page { + case "users": + data.AdminUsers, err = s.webAdminUsers() + case "devices": + data.AdminDevices, err = s.webAdminDevices() + case "vaults": + data.Vaults, err = s.webVaults() + case "audit": + data.Audit, err = s.webAudit() + case "settings": + data.SMTP = s.webSMTP() + } + if err != nil { + log.Printf("admin %s: %v", page, err) + s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/admin/dashboard") + return + } + s.renderPage(w, r, "admin", data) +} + +func (s *Server) webAdminUsers() ([]webAdminUser, error) { + 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 GROUP BY u.id ORDER BY u.created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []webAdminUser + 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 { + return nil, err + } + u.Confirmed = confirmed != 0 + u.Blocked = blocked != 0 + out = append(out, u) + } + return out, rows.Err() +} + +func (s *Server) webAdminDevices() ([]webAdminDevice, error) { + 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 ORDER BY d.created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + 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 { + return nil, err + } + d.Revoked = revoked != "" + if d.LastSeen == "" { + d.LastSeen = "—" + } + out = append(out, d) + } + return out, rows.Err() +} + +func (s *Server) webVaults() ([]webVault, error) { + rows, err := s.db.Query(`SELECT COALESCE(u.username,''),d.vault_id,COUNT(DISTINCT d.id),COUNT(o.op_id),COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE COALESCE(d.user_id,'')!='' AND COALESCE(d.vault_id,'')!='' GROUP BY d.user_id,d.vault_id ORDER BY MAX(d.last_seen) DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []webVault + for rows.Next() { + var v webVault + if err := rows.Scan(&v.User, &v.Vault, &v.Devices, &v.Operations, &v.LastActivity); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +func (s *Server) webAudit() ([]webAudit, error) { + rows, err := s.db.Query(`SELECT event_type,COALESCE(user_id,''),COALESCE(device_id,''),created_at FROM server_audit_log ORDER BY id DESC LIMIT 100`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []webAudit + for rows.Next() { + var a webAudit + if err := rows.Scan(&a.Event, &a.User, &a.Device, &a.At); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +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")} +} + +func (s *Server) adminReauth(r *http.Request, subject string) bool { + return s.cfg.CheckAdmin(subject, r.FormValue("password")) +} + +func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w, http.MethodPost) + return + } + session, ok := s.requireSession(w, r, sessionScopeAdmin) + if !ok || !s.verifyCSRF(w, r, session) { + return + } + if err := r.ParseForm(); err != nil { + s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/dashboard") + return + } + action := r.FormValue("action") + switch action { + case "toggle-user": + if !s.adminReauth(r, session.SubjectID) { + s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users") + return + } + id := r.FormValue("id") + tx, err := s.db.Begin() + if err != nil { + jsonInternalError(w, err) + return + } + defer tx.Rollback() + var blocked int + if err := tx.QueryRow("SELECT blocked FROM server_users WHERE id=?", id).Scan(&blocked); err != nil { + if err == sql.ErrNoRows { + s.renderWebError(w, r, http.StatusNotFound, "error.badRequest", "/admin/users") + return + } + jsonInternalError(w, err) + return + } + newValue := 1 + if blocked != 0 { + newValue = 0 + } + if _, err := tx.Exec("UPDATE server_users SET blocked=? WHERE id=?", newValue, id); err != nil { + jsonInternalError(w, err) + return + } + if newValue != 0 { + if _, err := tx.Exec("DELETE FROM server_sessions WHERE scope='user' AND subject_id=?", id); err != nil { + jsonInternalError(w, err) + return + } + } + if err := tx.Commit(); err != nil { + jsonInternalError(w, err) + return + } + s.auditLog("user_block_changed", id, "", s.clientIP(r), "changed by administrator") + http.Redirect(w, r, "/admin/users", http.StatusSeeOther) + case "edit-user": + id, username, email := r.FormValue("id"), strings.TrimSpace(r.FormValue("username")), strings.ToLower(strings.TrimSpace(r.FormValue("email"))) + if id == "" || username == "" || email == "" { + s.renderWebError(w, r, http.StatusBadRequest, "error.allFieldsRequired", "/admin/users") + return + } + if _, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", username, email, id); err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + s.renderWebError(w, r, http.StatusConflict, "error.accountTaken", "/admin/users") + return + } + jsonInternalError(w, err) + return + } + s.auditLog("user_updated", id, "", s.clientIP(r), "updated 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") + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + jsonInternalError(w, err) + return + } + tx, err := s.db.Begin() + if err != nil { + jsonInternalError(w, err) + return + } + defer tx.Rollback() + if _, err := tx.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), id); err != nil { + jsonInternalError(w, err) + return + } + if _, err := tx.Exec("DELETE FROM server_sessions WHERE scope='user' AND subject_id=?", id); err != nil { + jsonInternalError(w, err) + return + } + if err := tx.Commit(); err != nil { + jsonInternalError(w, err) + return + } + s.auditLog("user_password_reset", id, "", s.clientIP(r), "reset by administrator") + http.Redirect(w, r, "/admin/users", http.StatusSeeOther) + case "delete-user": + if !s.adminReauth(r, session.SubjectID) { + s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users") + return + } + id := r.FormValue("id") + tx, err := s.db.Begin() + if err != nil { + jsonInternalError(w, err) + return + } + defer tx.Rollback() + for _, statement := range []string{"DELETE FROM server_sessions WHERE subject_id=? AND scope='user'", "DELETE FROM server_email_tokens WHERE user_id=?", "DELETE FROM server_blob_refs WHERE user_id=?", "DELETE FROM server_idempotency_keys WHERE user_id=?", "DELETE FROM server_tombstones WHERE user_id=?", "DELETE FROM server_revisions WHERE op_id IN (SELECT op_id FROM server_ops WHERE user_id=?)", "DELETE FROM server_ops WHERE user_id=?", "DELETE FROM server_user_devices WHERE user_id=?", "DELETE FROM server_devices WHERE user_id=?", "DELETE FROM server_audit_log WHERE user_id=?", "DELETE FROM server_users 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("user_deleted", id, "", s.clientIP(r), "deleted by administrator") + http.Redirect(w, r, "/admin/users", http.StatusSeeOther) + case "revoke-device": + if !s.adminReauth(r, session.SubjectID) { + s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/devices") + return + } + id := r.FormValue("id") + if err := s.revokeDevice(id, time.Now().UTC().Format(time.RFC3339)); err != nil { + jsonInternalError(w, err) + return + } + s.auditLog("device_revoked", "", id, s.clientIP(r), "revoked 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") + return + } + tx, err := s.db.Begin() + if err != nil { + jsonInternalError(w, err) + return + } + defer tx.Rollback() + for _, key := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_security", "smtp_from", "server_url"} { + if _, err := tx.Exec("INSERT OR REPLACE INTO server_smtp_config (key, value) VALUES (?, ?)", key, r.FormValue(key)); err != nil { + jsonInternalError(w, err) + return + } + } + if pass := r.FormValue("smtp_pass"); pass != "" { + if _, err := tx.Exec("INSERT OR REPLACE INTO server_smtp_config (key, value) VALUES (?, ?)", "smtp_pass", pass); err != nil { + jsonInternalError(w, err) + return + } + } + if err := tx.Commit(); err != nil { + jsonInternalError(w, err) + return + } + s.auditLog("smtp_settings_updated", "", "", s.clientIP(r), "updated by administrator") + http.Redirect(w, r, "/admin/settings", http.StatusSeeOther) + default: + s.renderWebError(w, r, http.StatusBadRequest, "error.badRequest", "/admin/dashboard") + } +} + +func (s *Server) handleAdminWebLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w, http.MethodPost) + return + } + if !s.requireAdminMutation(w, r) { + return + } + if cookie, err := r.Cookie("admin_session"); err == nil { + if err := s.deleteSession(cookie.Value); err != nil { + jsonInternalError(w, err) + return + } + } + s.clearSessionCookies(w, r, sessionScopeAdmin) + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) +} diff --git a/internal/server/web_locale.go b/internal/server/web_locale.go new file mode 100644 index 0000000..27e0307 --- /dev/null +++ b/internal/server/web_locale.go @@ -0,0 +1,71 @@ +package server + +import ( + "net/http" + "strings" +) + +const webLocaleCookieName = "verstak_locale" + +func isSupportedWebLocale(locale string) bool { + return locale == "en" || locale == "ru" +} + +// resolveWebLocale has a deliberately small, documented locale model. The +// cookie is a user preference; "system" delegates to Accept-Language, then +// the configured server default, and finally English. +func resolveWebLocale(r *http.Request, cfg *Config) string { + configured := "en" + if cfg != nil && isSupportedWebLocale(cfg.Web.DefaultLocale) { + configured = cfg.Web.DefaultLocale + } + if cookie, err := r.Cookie(webLocaleCookieName); err == nil { + switch cookie.Value { + case "ru", "en": + return cookie.Value + case "system": + if locale := localeFromAcceptLanguage(r.Header.Get("Accept-Language")); locale != "" { + return locale + } + return configured + default: + return configured + } + } + if locale := localeFromAcceptLanguage(r.Header.Get("Accept-Language")); locale != "" { + return locale + } + return configured +} + +func localeFromAcceptLanguage(header string) string { + for _, part := range strings.Split(header, ",") { + language := strings.ToLower(strings.TrimSpace(strings.SplitN(part, ";", 2)[0])) + if language == "ru" || strings.HasPrefix(language, "ru-") { + return "ru" + } + if language == "en" || strings.HasPrefix(language, "en-") { + return "en" + } + } + return "" +} + +func (s *Server) webLocale(r *http.Request) string { + return resolveWebLocale(r, s.cfg) +} + +func (s *Server) webLocalePreference(r *http.Request) string { + if cookie, err := r.Cookie(webLocaleCookieName); err == nil && (cookie.Value == "ru" || cookie.Value == "en" || cookie.Value == "system") { + return cookie.Value + } + return "system" +} + +func (s *Server) setWebLocale(w http.ResponseWriter, r *http.Request, locale string) { + secure := s.requestIsHTTPS(r) + http.SetCookie(w, &http.Cookie{ + Name: webLocaleCookieName, Value: locale, Path: "/", HttpOnly: true, + Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: 365 * 24 * 60 * 60, + }) +} diff --git a/internal/server/web_locale_test.go b/internal/server/web_locale_test.go new file mode 100644 index 0000000..998ffd7 --- /dev/null +++ b/internal/server/web_locale_test.go @@ -0,0 +1,179 @@ +package server + +import ( + "io/fs" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestResolveWebLocaleCookieOverridesSystemAcceptLanguage(t *testing.T) { + cfg := DefaultConfig() + cfg.Web.DefaultLocale = "en" + req := httptest.NewRequest(http.MethodGet, "/login", nil) + req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8") + req.AddCookie(&http.Cookie{Name: webLocaleCookieName, Value: "en"}) + + if got := resolveWebLocale(req, cfg); got != "en" { + t.Fatalf("locale = %q, want cookie locale en", got) + } +} + +func TestTranslationCatalogsHaveMatchingKeysAndHideUnknownKeys(test *testing.T) { + for key := range _translations["en"] { + if _, ok := _translations["ru"][key]; !ok { + test.Fatalf("English key %q is missing in Russian catalog", key) + } + } + for key := range _translations["ru"] { + if _, ok := _translations["en"][key]; !ok { + test.Fatalf("Russian key %q is missing in English catalog", key) + } + } + if got := t("en", "missing.key"); got == "missing.key" || strings.Contains(got, "missing.key") { + test.Fatalf("unknown key leaked to UI: %q", got) + } +} + +func TestEmbeddedTemplatesUseExternalAssetsAndNoInlineEventHandlers(t *testing.T) { + entries, err := fs.Glob(webFS, "web/templates/*.html") + if err != nil { + t.Fatal(err) + } + for _, name := range entries { + body, err := webFS.ReadFile(name) + if err != nil { + t.Fatal(err) + } + text := string(body) + for _, forbidden := range []string{"`, `/static/app.css`, "Verstak Sync"} { + if !strings.Contains(body, want) { + t.Fatalf("home is missing %q: %s", want, body) + } + } + if strings.Contains(body, "