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
-
-`))
+ 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(`%s `, 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"}}
+ {{else if eq .AdminPage "devices"}}
{{t .Locale "admin.access"}}
{{t .Locale "admin.devices"}}
+ {{else if eq .AdminPage "vaults"}}
{{t .Locale "admin.storage"}}
{{t .Locale "admin.vaults"}}
+ {{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"}}
+ {{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"}}{{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}}{{t .Locale .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}}{{t .Locale .Flash}}
{{end}}{{t .Locale "user.devices"}} {{if .Devices}}{{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"}}{{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"}}
+
+{{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"}}
+
+{{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"}}{{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"}}
+
+{{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}}{{t .Locale .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{"