feat(web): add embedded localized sync console
This commit is contained in:
parent
b7f730cba9
commit
4b78f30a61
34
README.md
34
README.md
|
|
@ -83,6 +83,13 @@ retention:
|
|||
idempotency_hours: 24
|
||||
audit_days: 90
|
||||
temp_upload_hours: 24
|
||||
web:
|
||||
# Server default; visitors may choose System, Русский, or English in a cookie.
|
||||
default_locale: en
|
||||
# Set false for invite/admin-only installations.
|
||||
allow_registration: true
|
||||
# Product name rendered in the embedded web console.
|
||||
server_name: Verstak Sync Server
|
||||
```
|
||||
|
||||
Production installs use:
|
||||
|
|
@ -217,6 +224,33 @@ Operational endpoints:
|
|||
- `/admin/...` - Admin web UI and admin JSON endpoints
|
||||
- `/register`, `/login`, `/dashboard`, `/forgot`, `/reset`, `/logout` - User web UI
|
||||
|
||||
## Embedded web console
|
||||
|
||||
The server embeds its public, account, and administrator interface in the Go
|
||||
binary. It has no CDN, npm build, external font, or remote analytics
|
||||
dependency. `/` is a localized public page; `/login`, `/register`, `/forgot`,
|
||||
and `/reset` use post/redirect/get flows. `/dashboard` lets a signed-in user
|
||||
review their own devices and revoke one only after entering their password.
|
||||
|
||||
`/admin/login` opens the administrator console. Its sidebar provides overview,
|
||||
users, devices, vaults, storage, audit, SMTP settings, and diagnostics. User
|
||||
blocking is immediate; device revocation and SMTP changes require the current
|
||||
administrator password again. Admin HTML is deliberately a normal server
|
||||
rendered control plane; the existing `/admin/api/...` endpoints remain for
|
||||
automation.
|
||||
|
||||
The locale resolver uses the `verstak_locale` HttpOnly/Lax cookie first. Its
|
||||
values are `ru`, `en`, or `system`; `system` uses `Accept-Language`, then
|
||||
`web.default_locale`, then English. The choice survives login and logout.
|
||||
Registration is controlled by `web.allow_registration`; when disabled the
|
||||
public registration page does not expose account creation.
|
||||
|
||||
All browser mutations use POST and validate a server-side session plus CSRF
|
||||
token. The server returns security headers including a restrictive CSP,
|
||||
`frame-ancestors 'none'`, `nosniff`, and a same-origin referrer policy. The
|
||||
console must still be deployed behind the HTTPS reverse proxy described below:
|
||||
secure cookies are enabled when HTTPS is detected through a trusted proxy.
|
||||
|
||||
Sync operations are generic records with `entity_type`, `entity_id`, `op_type`,
|
||||
`payload_json`, `device_id`, and sequencing metadata. A pairing token is bound
|
||||
to one user and vault. The server derives the stored device ID and operation
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(userRegisterHTML(locale)))
|
||||
case "POST":
|
||||
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")))
|
||||
if !s.cfg.Web.AllowRegistration {
|
||||
s.renderWebError(w, r, http.StatusNotFound, "error.registrationDisabled", "/login")
|
||||
return
|
||||
}
|
||||
username := r.FormValue("username")
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle"})
|
||||
case http.MethodPost:
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/register")
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
tokenStr, err := issueEmailToken(s.db, userID, "confirm", 48*time.Hour)
|
||||
log.Printf("web register: create user: %v", err)
|
||||
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/register")
|
||||
return
|
||||
}
|
||||
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)))
|
||||
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
|
||||
}
|
||||
tokenStr, err := issueEmailToken(s.db, userID, "reset", time.Hour)
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
if s.smtpGet("smtp_host") != "" {
|
||||
base := s.smtpGet("server_url")
|
||||
if base == "" {
|
||||
base = "http://" + r.Host
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
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, tokenStr)
|
||||
log.Printf("development reset token requested for %s: %s", email, token)
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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()
|
||||
var devices []webDevice
|
||||
for rows.Next() {
|
||||
var d dev
|
||||
rows.Scan(&d.ID, &d.Name, &d.LastSeen, &d.CreatedAt, &d.ClientVer, &d.RevokedAt)
|
||||
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
|
||||
}
|
||||
d.Revoked = revoked != ""
|
||||
if d.LastSeen == "" {
|
||||
d.LastSeen = "—"
|
||||
}
|
||||
devices = append(devices, d)
|
||||
}
|
||||
if err := rows.Err(); 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 = "—"
|
||||
flash := r.URL.Query().Get("error")
|
||||
if flash != "error.invalidCredentials" {
|
||||
flash = ""
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
csrf := ""
|
||||
if cookie, err := r.Cookie("csrf_token"); err == nil {
|
||||
csrf = cookie.Value
|
||||
}
|
||||
w.Write([]byte(userDashboardHTML(locale, html.EscapeString(username), deviceRows, csrf)))
|
||||
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"), "/")
|
||||
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
|
||||
}
|
||||
if req.Password == "" || !s.allowRate(w, r, "auth-test", session.SubjectID) {
|
||||
if req.Password == "" {
|
||||
password = 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 {
|
||||
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"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; } }
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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 |
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue