feat(web): add embedded localized sync console

This commit is contained in:
mirivlad 2026-07-17 05:44:27 +08:00
parent b7f730cba9
commit 4b78f30a61
27 changed files with 1497 additions and 258 deletions

View File

@ -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

View File

@ -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)

View File

@ -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(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Admin Login</title>
<style>body{font-family:sans-serif;background:#1a1a2e;color:#e0e0f0;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
form{background:#16213e;padding:2rem;border-radius:8px;border:1px solid #0f3460;width:300px}
h2{margin:0 0 1rem;color:#e0e0f0}label{display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem}
input{width:100%;background:#0f3460;border:1px solid #1a3a5c;color:#e0e0f0;padding:8px 10px;border-radius:4px;font-size:0.85rem;box-sizing:border-box;margin-bottom:0.75rem}
button{background:#4ecca3;color:#1a1a2e;border:none;padding:0.5rem 1rem;border-radius:4px;cursor:pointer;font-weight:600;width:100%}</style></head>
<body><form method="POST"><h2>Admin Login</h2>
<label>Username</label><input name="username" required>
<label>Password</label><input type="password" name="password" required>
<button type="submit">Login</button></form></body></html>`))
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)
}
}

View File

@ -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")

View File

@ -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 = "<tr><td colspan='5' style='color:#666;text-align:center;padding:24px'>" + t(locale, "userDashboard.noDevices") + "</td></tr>"
} else {
for _, d := range devices {
ls := d.LastSeen
if ls == "" {
ls = "—"
}
created := d.CreatedAt
if len(created) > 10 {
created = created[:10]
}
status := "<span style='color:#34d399'>" + t(locale, "userDashboard.active") + "</span>"
revokeBtn := fmt.Sprintf(`<button class="btn btn-danger btn-sm" onclick="revokeDevice(%s)">%s</button>`, html.EscapeString(strconv.Quote(d.ID)), t(locale, "userDashboard.revoke"))
if d.RevokedAt != "" {
status = "<span style='color:#ff6b6b'>" + t(locale, "userDashboard.revoked") + "</span>"
revokeBtn = ""
}
deviceRows += fmt.Sprintf(`<tr>
<td>%s</td>
<td>%s</td>
<td>%s</td>
<td>%s</td>
<td>%s %s</td>
</tr>`, 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"})
}

View File

@ -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",

View File

@ -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)
}

View File

@ -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

View File

@ -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; } }

View File

@ -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();
});

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none"><path fill="#4fd1b5" d="M8 10h48v12H8zM14 26h36v12H14zM20 42h24v12H20z"/><path stroke="#e5eef1" stroke-width="4" d="M10 8h44v48H10z"/></svg>

After

Width:  |  Height:  |  Size: 213 B

View File

@ -0,0 +1,27 @@
{{define "admin"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">
<aside class="admin-nav" aria-label="{{t .Locale "admin.navigation"}}">
<p class="eyebrow">{{t .Locale "admin.eyebrow"}}</p>
<a href="/admin/dashboard" {{if eq .AdminPage "dashboard"}}aria-current="page"{{end}}>{{t .Locale "admin.overview"}}</a>
<a href="/admin/users" {{if eq .AdminPage "users"}}aria-current="page"{{end}}>{{t .Locale "admin.users"}}</a>
<a href="/admin/devices" {{if eq .AdminPage "devices"}}aria-current="page"{{end}}>{{t .Locale "admin.devices"}}</a>
<a href="/admin/vaults" {{if eq .AdminPage "vaults"}}aria-current="page"{{end}}>{{t .Locale "admin.vaults"}}</a>
<a href="/admin/storage" {{if eq .AdminPage "storage"}}aria-current="page"{{end}}>{{t .Locale "admin.storage"}}</a>
<a href="/admin/audit" {{if eq .AdminPage "audit"}}aria-current="page"{{end}}>{{t .Locale "admin.audit"}}</a>
<a href="/admin/settings" {{if eq .AdminPage "settings"}}aria-current="page"{{end}}>{{t .Locale "admin.settings"}}</a>
<a href="/admin/diagnostics" {{if eq .AdminPage "diagnostics"}}aria-current="page"{{end}}>{{t .Locale "admin.diagnostics"}}</a>
<form method="post" action="/admin/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="link-button" type="submit">{{t .Locale "auth.logout"}}</button></form>
</aside>
<div class="admin-content">
{{if eq .AdminPage "dashboard"}}<p class="eyebrow">{{t .Locale "admin.overview"}}</p><h1>{{t .Locale "admin.dashboard"}}</h1><div class="stat-grid"><article class="card stat"><strong>{{.Stats.Users}}</strong><span>{{t .Locale "admin.users"}}</span></article><article class="card stat"><strong>{{.Stats.ActiveDevices}}</strong><span>{{t .Locale "admin.activeDevices"}}</span></article><article class="card stat"><strong>{{.Stats.Vaults}}</strong><span>{{t .Locale "admin.vaults"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article></div><section class="card panel"><h2>{{t .Locale "admin.serviceHealth"}}</h2><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{.Health.Status}}</dd><dt>{{t .Locale "admin.version"}}</dt><dd>{{.Health.Version}} {{.Health.BuildCommit}}</dd><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{.Stats.LastSyncAt}}</dd></dl></section>
{{else if eq .AdminPage "users"}}<div class="section-heading"><div><p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.users"}}</h1></div><a class="button primary" href="/admin/create-user">{{t .Locale "admin.createUser"}}</a></div><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "field.username"}}</th><th>{{t .Locale "field.email"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .AdminUsers}}<tr><td>{{.Username}}</td><td>{{.Email}}</td><td>{{.Devices}}</td><td>{{if .Blocked}}<span class="badge danger">{{t $.Locale "admin.blocked"}}</span>{{else if .Confirmed}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{else}}<span class="badge">{{t $.Locale "admin.unconfirmed"}}</span>{{end}}</td><td><form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="toggle-user"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" required placeholder="{{t $.Locale "field.password"}}"><button class="button secondary" type="submit">{{if .Blocked}}{{t $.Locale "admin.unblock"}}{{else}}{{t $.Locale "admin.block"}}{{end}}</button></form><details><summary>{{t $.Locale "admin.manage"}}</summary><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="edit-user"><label>{{t $.Locale "field.username"}}<input name="username" value="{{.Username}}" required></label><label>{{t $.Locale "field.email"}}<input name="email" type="email" value="{{.Email}}" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.saveUser"}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="reset-user-password"><label>{{t $.Locale "field.newPassword"}}<input name="new_password" type="password" required></label><label>{{t $.Locale "field.password"}}<input name="password" type="password" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.resetPassword"}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="delete-user"><label>{{t $.Locale "field.password"}}<input name="password" type="password" required></label><button class="button danger" type="submit">{{t $.Locale "admin.deleteUser"}}</button></form></details></td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noUsers"}}</td></tr>{{end}}</tbody></table></div></section>
{{else if eq .AdminPage "devices"}}<p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.devices"}}</h1><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .AdminDevices}}<tr><td>{{.Name}}</td><td>{{.User}}</td><td class="mono">{{short .Vault 16}}</td><td>{{.LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td><td>{{if not .Revoked}}<form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="revoke-device"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit">{{t $.Locale "device.revoke"}}</button></form>{{end}}</td></tr>{{else}}<tr><td colspan="6" class="empty">{{t .Locale "admin.noDevices"}}</td></tr>{{end}}</tbody></table></div></section>
{{else if eq .AdminPage "vaults"}}<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.vaults"}}</h1><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "admin.operations"}}</th><th>{{t .Locale "admin.lastActivity"}}</th></tr></thead><tbody>{{range .Vaults}}<tr><td>{{.User}}</td><td class="mono">{{short .Vault 24}}</td><td>{{.Devices}}</td><td>{{.Operations}}</td><td>{{.LastActivity}}</td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noVaults"}}</td></tr>{{end}}</tbody></table></div></section>
{{else if eq .AdminPage "storage"}}<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.storage"}}</h1><div class="stat-grid"><article class="card stat"><strong>{{.Stats.DatabaseBytes}}</strong><span>{{t .Locale "admin.databaseBytes"}}</span></article><article class="card stat"><strong>{{.Stats.BlobBytes}}</strong><span>{{t .Locale "admin.blobBytes"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article></div><p class="muted">{{t .Locale "admin.retentionNote"}}</p>
{{else if eq .AdminPage "audit"}}<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.audit"}}</h1><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.event"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.time"}}</th></tr></thead><tbody>{{range .Audit}}<tr><td>{{.Event}}</td><td>{{.User}}</td><td>{{.Device}}</td><td>{{.At}}</td></tr>{{else}}<tr><td colspan="4" class="empty">{{t .Locale "admin.noAudit"}}</td></tr>{{end}}</tbody></table></div></section>
{{else if eq .AdminPage "settings"}}<p class="eyebrow">{{t .Locale "admin.settings"}}</p><h1>{{t .Locale "admin.smtpTitle"}}</h1><section class="card panel"><form method="post" action="/admin/action" class="stack"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="smtp"><label>{{t .Locale "admin.smtpServer"}}<input name="smtp_host" value="{{.SMTP.Host}}"></label><label>{{t .Locale "admin.smtpPort"}}<input name="smtp_port" value="{{.SMTP.Port}}"></label><label>{{t .Locale "admin.smtpUsername"}}<input name="smtp_user" value="{{.SMTP.User}}"></label><label>{{t .Locale "admin.smtpPassword"}}<input name="smtp_pass" type="password" autocomplete="new-password"></label><label>{{t .Locale "admin.smtpType"}}<select name="smtp_security"><option value="none" {{if eq .SMTP.Security "none"}}selected{{end}}>{{t .Locale "admin.smtpNoEncryption"}}</option><option value="starttls" {{if eq .SMTP.Security "starttls"}}selected{{end}}>STARTTLS</option><option value="tls" {{if eq .SMTP.Security "tls"}}selected{{end}}>TLS</option></select></label><label>{{t .Locale "admin.smtpFrom"}}<input name="smtp_from" value="{{.SMTP.From}}"></label><label>{{t .Locale "admin.smtpServerURL"}}<input name="server_url" value="{{.SMTP.ServerURL}}"></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "admin.smtpSave"}}</button></form></section>
{{else if eq .AdminPage "diagnostics"}}<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.diagnostics"}}</h1><section class="card panel"><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{.Health.Status}}</dd><dt>{{t .Locale "admin.database"}}</dt><dd>{{.Health.DatabaseReachable}}</dd><dt>{{t .Locale "admin.blobStorage"}}</dt><dd>{{.Health.BlobStorageWritable}}</dd><dt>{{t .Locale "admin.schema"}}</dt><dd>{{.Health.SchemaVersion}}</dd><dt>{{t .Locale "admin.serverTime"}}</dt><dd>{{.Health.ServerTime}}</dd></dl><a class="button secondary" href="/api/v1/health">{{t .Locale "admin.healthJSON"}}</a></section>{{end}}
</div>
</section>
{{end}}

View File

@ -0,0 +1,2 @@
{{define "admin_create_user"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.createUser"}}</h1>{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}<form method="post" action="/admin/create-user" class="stack"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "admin.createUser"}}</button></form><p class="muted"><a href="/admin/users">{{t .Locale "common.back"}}</a></p></section>{{end}}

View File

@ -0,0 +1,2 @@
{{define "admin_login"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "admin.eyebrow"}}</p><h1>{{t .Locale "admin.loginTitle"}}</h1>{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}<form method="post" action="/admin/login" class="stack"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "auth.login"}}</button></form></section>{{end}}

View File

@ -0,0 +1,2 @@
{{define "dashboard"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="dashboard-head"><div><p class="eyebrow">{{t .Locale "user.account"}}</p><h1>{{.UserName}}</h1><p class="muted">{{.Email}}</p></div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="button secondary" type="submit">{{t .Locale "auth.logout"}}</button></form></section>{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}<section class="card table-card"><h2>{{t .Locale "user.devices"}}</h2>{{if .Devices}}<div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "device.version"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .Devices}}<tr><td>{{.Name}}</td><td><span class="mono">{{short .Vault 18}}</span></td><td>{{.ClientVersion}}</td><td>{{.LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td><td>{{if not .Revoked}}<form method="post" action="/api/v1/user/devices/{{.ID}}/revoke" class="inline-form"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><label class="sr-only">{{t $.Locale "field.password"}}</label><input name="password" type="password" autocomplete="current-password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit" data-confirm="{{t $.Locale "device.revokeConfirm"}}">{{t $.Locale "device.revoke"}}</button></form>{{end}}</td></tr>{{end}}</tbody></table></div>{{else}}<p class="empty">{{t .Locale "user.noDevices"}}</p>{{end}}</section>{{end}}

View File

@ -0,0 +1,2 @@
{{define "error"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="message-card card"><p class="eyebrow danger-text">{{t .Locale "error.label"}}</p><h1>{{t .Locale .Heading}}</h1><p class="lead">{{t .Locale .Message}}</p>{{if .BackURL}}<a class="button secondary" href="{{.BackURL}}">{{t .Locale "common.back"}}</a>{{end}}</section>{{end}}

View File

@ -0,0 +1,5 @@
{{define "forgot"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.forgotTitle"}}</h1><p class="muted">{{t .Locale "auth.forgotDescription"}}</p>
<form method="post" action="/forgot" class="stack"><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required autofocus></label><button class="button primary" type="submit">{{t .Locale "auth.sendLink"}}</button></form><p class="muted"><a href="/login">{{t .Locale "auth.backLogin"}}</a></p></section>
{{end}}

View File

@ -0,0 +1,10 @@
{{define "home"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="hero card">
<p class="eyebrow">{{t .Locale "home.eyebrow"}}</p>
<h1>{{t .Locale "home.heading"}}</h1>
<p class="lead">{{t .Locale "home.description"}}</p>
<div class="actions"><a class="button primary" href="/login">{{t .Locale "home.login"}}</a>{{if .AllowRegistration}}<a class="button secondary" href="/register">{{t .Locale "home.register"}}</a>{{end}}</div>
<p class="muted">{{t .Locale "home.version"}} {{.Version}} · {{.BuildCommit}} · <span class="badge ok">{{.Status}}</span></p>
</section>
{{end}}

View File

@ -0,0 +1,35 @@
{{define "layout"}}
<!doctype html>
<html lang="{{.Locale}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{t .Locale .Title}} · {{.ServerName}}</title>
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<header class="site-header">
<a class="brand" href="/" aria-label="{{.ServerName}}"><img src="/static/logo.svg" alt="" width="28" height="28"><span>Verstak Sync</span></a>
<nav class="site-nav" aria-label="{{t .Locale "nav.primary"}}">
<a href="/login">{{t .Locale "nav.login"}}</a>
{{if .AllowRegistration}}<a href="/register">{{t .Locale "nav.register"}}</a>{{end}}
</nav>
<form class="locale-form" action="/locale" method="post" aria-label="{{t .Locale "locale.label"}}">
<input type="hidden" name="from" value="{{.CurrentURL}}">
{{if .CSRF}}<input type="hidden" name="csrf_token" value="{{.CSRF}}">{{end}}
<label class="sr-only" for="locale">{{t .Locale "locale.label"}}</label>
<select id="locale" name="locale" data-auto-submit>
<option value="system" {{if eq .LocalePreference "system"}}selected{{end}}>{{t .Locale "locale.system"}}</option>
<option value="ru" {{if eq .LocalePreference "ru"}}selected{{end}}>Русский</option>
<option value="en" {{if eq .LocalePreference "en"}}selected{{end}}>English</option>
</select>
<button class="sr-only" type="submit">{{t .Locale "locale.apply"}}</button>
</form>
</header>
<main class="page-shell">{{template "content" .}}</main>
<footer class="site-footer"><span>{{.ServerName}}</span><span>{{t .Locale "footer.localFirst"}}</span></footer>
<script src="/static/app.js" defer></script>
</body>
</html>
{{end}}

View File

@ -0,0 +1,7 @@
{{define "login"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.welcome"}}</p><h1>{{t .Locale "auth.loginTitle"}}</h1>
{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}
<form method="post" action="/login" class="stack"><label>{{t .Locale "field.usernameOrEmail"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "auth.login"}}</button></form>
<p class="muted"><a href="/forgot">{{t .Locale "auth.forgot"}}</a>{{if .AllowRegistration}} · <a href="/register">{{t .Locale "auth.register"}}</a>{{end}}</p></section>
{{end}}

View File

@ -0,0 +1,2 @@
{{define "message"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="message-card card"><p class="eyebrow">Verstak Sync</p><h1>{{t .Locale .Heading}}</h1><p class="lead">{{t .Locale .Message}}</p>{{if .BackURL}}<a class="button primary" href="{{.BackURL}}">{{t .Locale "common.continue"}}</a>{{end}}</section>{{end}}

View File

@ -0,0 +1,7 @@
{{define "register"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.registerTitle"}}</h1>
{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}
<form method="post" action="/register" class="stack"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "auth.register"}}</button></form>
<p class="muted"><a href="/login">{{t .Locale "auth.haveAccount"}}</a></p></section>
{{end}}

View File

@ -0,0 +1,6 @@
{{define "reset"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.resetTitle"}}</h1>
{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}
<form method="post" action="/reset" class="stack"><input type="hidden" name="token" value="{{.Token}}"><label>{{t .Locale "field.newPassword"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required autofocus></label><label>{{t .Locale "field.confirmPassword"}}<input name="confirm" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "auth.savePassword"}}</button></form></section>
{{end}}

View File

@ -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)
}

View File

@ -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,
})
}

View File

@ -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{"<style", " onclick=", " onchange="} {
if strings.Contains(strings.ToLower(text), forbidden) {
t.Fatalf("%s contains forbidden inline asset/handler %q", name, forbidden)
}
}
}
}
func TestPublicHomeUsesSharedLocalizedTemplateLayout(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Accept-Language", "ru-RU")
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("home status = %d", res.Code)
}
body := res.Body.String()
for _, want := range []string{`<html lang="ru">`, `/static/app.css`, "Verstak Sync"} {
if !strings.Contains(body, want) {
t.Fatalf("home is missing %q: %s", want, body)
}
}
if strings.Contains(body, "<style>") {
t.Fatalf("home must use embedded static CSS, not inline style: %s", body)
}
}
func TestLocaleSelectionUsesCookieAndPRG(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/login"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/login" {
t.Fatalf("locale response = %d %q", res.Code, res.Header().Get("Location"))
}
cookie := res.Result().Cookies()[0]
if cookie.Name != webLocaleCookieName || cookie.Value != "ru" || !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode {
t.Fatalf("unexpected locale cookie: %#v", cookie)
}
}
func TestLocaleSelectionPreservesResetQuery(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/reset%3Ftoken%3Dopaque-token"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/reset?token=opaque-token" {
t.Fatalf("locale redirect=%d %q", res.Code, res.Header().Get("Location"))
}
}
func TestWebResponsesHaveSecurityHeaders(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/login", nil))
for name, value := range map[string]string{"X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "same-origin"} {
if got := res.Header().Get(name); got != value {
t.Fatalf("%s=%q, want %q", name, got, value)
}
}
if got := res.Header().Get("Content-Security-Policy"); !strings.Contains(got, "default-src 'self'") || !strings.Contains(got, "frame-ancestors 'none'") {
t.Fatalf("CSP=%q", got)
}
}
func TestAdminLoginUsesSharedTemplateAndAdminRootRedirects(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
login := httptest.NewRecorder()
s.Handler().ServeHTTP(login, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
if login.Code != http.StatusOK || !strings.Contains(login.Body.String(), "/static/app.css") || strings.Contains(login.Body.String(), "<style>") {
t.Fatalf("admin login did not use shared template: %d %s", login.Code, login.Body.String())
}
root := httptest.NewRecorder()
s.Handler().ServeHTTP(root, httptest.NewRequest(http.MethodGet, "/admin", nil))
if root.Code != http.StatusFound || root.Header().Get("Location") != "/admin/dashboard" {
t.Fatalf("admin root=%d %q", root.Code, root.Header().Get("Location"))
}
}
func TestResolveWebLocaleSystemUsesAcceptLanguageAndFallsBack(t *testing.T) {
cfg := DefaultConfig()
cfg.Web.DefaultLocale = "en"
for _, test := range []struct {
name string
cookie string
header string
want string
}{
{"system russian", "system", "ru-RU,ru;q=0.9", "ru"},
{"unknown cookie", "de", "ru-RU", "en"},
{"no header", "system", "", "en"},
} {
t.Run(test.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/login", nil)
req.Header.Set("Accept-Language", test.header)
req.AddCookie(&http.Cookie{Name: webLocaleCookieName, Value: test.cookie})
if got := resolveWebLocale(req, cfg); got != test.want {
t.Fatalf("locale = %q, want %q", got, test.want)
}
})
}
}

View File

@ -0,0 +1,228 @@
package server
import (
"embed"
"html/template"
"io/fs"
"net/http"
"strings"
"time"
)
//go:embed web/templates/*.html web/static/*
var webFS embed.FS
type webRenderer struct {
templates map[string]*template.Template
static fs.FS
}
type webPage struct {
Locale string
LocalePreference string
Title string
ServerName string
CurrentPath string
CurrentURL string
CSRF string
Flash string
AllowRegistration bool
Version string
BuildCommit string
Now time.Time
Heading string
Message string
Status string
FormAction string
BackURL string
Token string
Admin bool
UserName string
Email string
Devices []webDevice
AdminPage string
Stats ServerStats
Health HealthStatus
AdminUsers []webAdminUser
AdminDevices []webAdminDevice
Vaults []webVault
Audit []webAudit
SMTP webSMTP
}
type webAdminUser struct {
ID, Username, Email, CreatedAt, LastSeen string
Confirmed, Blocked bool
Devices int
}
type webAdminDevice struct {
ID, Name, User, Vault, Version, LastSeen, CreatedAt string
Revoked bool
}
type webVault struct {
User, Vault string
Devices, Operations int
LastActivity string
}
type webAudit struct{ Event, User, Device, At string }
type webSMTP struct{ Host, Port, User, Security, From, ServerURL string }
type webDevice struct {
ID string
Name string
Vault string
ClientVersion string
CreatedAt string
LastSeen string
Revoked bool
TokenHint string
}
func newWebRenderer() (*webRenderer, error) {
funcs := template.FuncMap{
"t": func(locale, key string) string { return t(locale, key) },
"short": func(value string, length int) string {
if len(value) <= length || length < 5 {
return value
}
return value[:length-1] + "…"
},
}
layout, err := template.New("layout.html").Funcs(funcs).ParseFS(webFS, "web/templates/layout.html")
if err != nil {
return nil, err
}
renderer := &webRenderer{templates: make(map[string]*template.Template)}
for _, page := range []string{"home", "login", "register", "forgot", "reset", "message", "error", "admin_login", "dashboard", "admin", "admin_create_user"} {
clone, err := layout.Clone()
if err != nil {
return nil, err
}
if _, err := clone.ParseFS(webFS, "web/templates/"+page+".html"); err != nil {
return nil, err
}
renderer.templates[page] = clone
}
renderer.static, err = fs.Sub(webFS, "web/static")
if err != nil {
return nil, err
}
return renderer, nil
}
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page string, data webPage) {
s.renderPageStatus(w, r, page, data, http.StatusOK)
}
func (s *Server) renderPageStatus(w http.ResponseWriter, r *http.Request, page string, data webPage, status int) {
if s.web == nil || s.web.templates[page] == nil {
jsonInternalError(w, errWebTemplateUnavailable)
return
}
data.Locale = s.webLocale(r)
data.LocalePreference = s.webLocalePreference(r)
data.ServerName = s.cfg.Web.ServerName
data.CurrentPath = r.URL.Path
data.CurrentURL = r.URL.RequestURI()
data.AllowRegistration = s.cfg.Web.AllowRegistration
data.Version = Version
data.BuildCommit = BuildCommit
data.Now = time.Now().UTC()
if cookie, err := r.Cookie("csrf_token"); err == nil {
data.CSRF = cookie.Value
}
if data.Admin || data.UserName != "" {
w.Header().Set("Cache-Control", "no-store")
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
if status != http.StatusOK {
w.WriteHeader(status)
}
if err := s.web.templates[page].ExecuteTemplate(w, page, data); err != nil {
jsonInternalError(w, err)
}
}
var errWebTemplateUnavailable = &webTemplateError{}
type webTemplateError struct{}
func (*webTemplateError) Error() string { return "web template unavailable" }
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
methodNotAllowed(w, http.MethodGet, http.MethodHead)
return
}
http.StripPrefix("/static/", http.FileServer(http.FS(s.web.static))).ServeHTTP(w, r)
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
s.handleNotFound(w, r)
return
}
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
status := s.healthStatus(r.Context())
s.renderPage(w, r, "home", webPage{Title: "home.title", Status: status.Status})
}
func (s *Server) handleLocale(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
if err := r.ParseForm(); err != nil {
s.renderPage(w, r, "error", webPage{Title: "error.badRequest", Heading: "error.badRequest", Message: "error.tryAgain"})
return
}
locale := r.FormValue("locale")
if locale != "ru" && locale != "en" && locale != "system" {
locale = "system"
}
s.setWebLocale(w, r, locale)
from := r.FormValue("from")
if !strings.HasPrefix(from, "/") || strings.HasPrefix(from, "//") {
from = "/"
}
http.Redirect(w, r, from, http.StatusSeeOther)
}
func (s *Server) handleRegistrationResult(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
s.renderPage(w, r, "message", webPage{Title: "register.resultTitle", Heading: "register.resultTitle", Message: "register.resultMessage", BackURL: "/login"})
}
func (s *Server) handleForgotSent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
s.renderPage(w, r, "message", webPage{Title: "forgot.sentTitle", Heading: "forgot.sentTitle", Message: "forgot.sentMessage", BackURL: "/login"})
}
func (s *Server) handleResetDone(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
w.Header().Set("Cache-Control", "no-store")
s.renderPage(w, r, "message", webPage{Title: "reset.doneTitle", Heading: "reset.doneTitle", Message: "reset.doneMessage", BackURL: "/login"})
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; script-src 'self'; style-src 'self'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}