verstak-sync-server/internal/server/web_render.go

372 lines
12 KiB
Go

package server
import (
"embed"
"html/template"
"io/fs"
"net/http"
"net/url"
"strconv"
"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
DefaultLocale string
Title string
ServerName string
PublicURL string
TrustedProxies string
Limits Limits
CurrentPath string
CurrentURL string
CSRF string
LocaleCSRF string
Flash string
FlashError bool
AllowRegistration bool
Version string
BuildCommit string
Now time.Time
Heading string
Message string
Status string
FormAction string
BackURL string
Token string
OneTimeSecret string
Admin bool
UserName string
Email string
UserConfirmed bool
Devices []webDevice
AdminPage string
Stats ServerStats
Health HealthStatus
AdminUsers []webAdminUser
AdminDevices []webAdminDevice
Vaults []webVault
VaultDevices []webAdminDevice
Audit []webAudit
Warnings []string
SMTP webSMTP
List webList
VaultDetail webVaultDetail
}
type webAdminUser struct {
ID, Username, Email, CreatedAt, LastSeen string
Confirmed, Blocked bool
Devices, Vaults int
}
type webAdminDevice struct {
ID, Name, User, Vault, Version, LastIP, LastSeen, CreatedAt, TokenHint string
Revoked bool
}
type webVault struct {
User, UserID, Vault string
Devices, Operations int
LastActivity string
}
type webAudit struct{ Event, User, Device, IP, Message, Severity, At string }
type webSMTP struct{ Host, Port, User, Security, From, ServerURL string }
type webList struct {
Query, Status, Sort, User, Vault, Version, Event, Severity string
Page int
PerPage int
Total int
Pages int
Previous int
Next int
}
func (list webList) params(page int) string {
values := url.Values{}
for key, value := range map[string]string{"q": list.Query, "status": list.Status, "sort": list.Sort, "user": list.User, "vault": list.Vault, "version": list.Version, "event": list.Event, "severity": list.Severity} {
if value != "" {
values.Set(key, value)
}
}
if page > 1 {
values.Set("page", strconvItoa(page))
}
if list.PerPage != 25 {
values.Set("per_page", strconvItoa(list.PerPage))
}
return values.Encode()
}
type webVaultDetail struct {
User, Vault, LastActivity string
Devices, Active, Revoked int
Operations, Sequence int
BlobBytes int64
}
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) },
"webtime": func(locale, value string) string { return formatWebTime(locale, value) },
"webbytes": func(value int64) string { return formatWebBytes(value) },
"listparams": func(list webList, page int) string { return list.params(page) },
"auditlabel": func(locale, event string) string { return auditEventLabel(locale, event) },
"statuslabel": func(locale, status string) string { return statusLabel(locale, status) },
"boollabel": func(locale string, value bool) string { return boolLabel(locale, value) },
"short": func(value string, length int) string {
if len(value) <= length || length < 5 {
return value
}
return value[:length-1] + "…"
},
}
layout, err := template.New("layout.html").Funcs(funcs).ParseFS(webFS, "web/templates/layout.html", "web/templates/admin_nav.html")
if err != nil {
return nil, err
}
renderer := &webRenderer{templates: make(map[string]*template.Template)}
for _, page := range []string{"home", "unavailable", "login", "register", "forgot", "reset", "confirm", "message", "error", "admin_login", "dashboard", "admin_dashboard", "admin_users", "admin_devices", "admin_vaults", "admin_storage", "admin_audit", "admin_diagnostics", "admin_create_user", "admin_password_result", "vault_detail", "admin_settings"} {
clone, err := layout.Clone()
if err != nil {
return nil, err
}
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 formatWebTime(locale, value string) string {
if value == "" {
return "—"
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return value
}
if locale == "ru" {
return parsed.Local().Format("02.01.2006 15:04")
}
return parsed.Local().Format("Jan 2, 2006 15:04")
}
func formatWebBytes(value int64) string {
if value < 1024 {
return strconvItoa(int(value)) + " B"
}
units := []string{"KB", "MB", "GB", "TB"}
amount := float64(value)
for _, unit := range units {
amount /= 1024
if amount < 1024 || unit == "TB" {
return strconv.FormatFloat(amount, 'f', 1, 64) + " " + unit
}
}
return "0 B"
}
func auditEventLabel(locale, event string) string {
keys := map[string]string{
"device_auth_failed": "audit.deviceAuthFailed",
"device_paired": "audit.devicePaired",
"device_revoked": "audit.deviceRevoked",
"device_deleted": "audit.deviceDeleted",
"rate_limit_exceeded": "audit.rateLimited",
"retention_cleanup": "audit.retentionCleanup",
"smtp_settings_updated": "audit.smtpSettingsUpdated",
"smtp_test_failed": "audit.smtpTestFailed",
"smtp_test_passed": "audit.smtpTestPassed",
"user_block_changed": "audit.userBlockChanged",
"user_confirmed": "audit.userConfirmed",
"user_created": "audit.userCreated",
"user_deleted": "audit.userDeleted",
"user_password_reset": "audit.userPasswordReset",
"user_updated": "audit.userUpdated",
"web_settings_updated": "audit.webSettingsUpdated",
}
if key := keys[event]; key != "" {
return t(locale, key)
}
return t(locale, "audit.other")
}
func statusLabel(locale, status string) string {
if status == "ok" {
return t(locale, "status.ok")
}
return t(locale, "status.degraded")
}
func boolLabel(locale string, value bool) string {
if value {
return t(locale, "status.available")
}
return t(locale, "status.unavailable")
}
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page string, data webPage) {
s.renderPageStatus(w, r, page, data, http.StatusOK)
}
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.DefaultLocale = s.cfg.Web.DefaultLocale
data.ServerName = s.cfg.Web.ServerName
data.PublicURL = s.cfg.PublicURL
data.TrustedProxies = strings.Join(s.cfg.TrustedProxies, ", ")
data.Limits = s.cfg.Limits
data.CurrentPath = r.URL.Path
data.CurrentURL = r.URL.RequestURI()
data.LocaleCSRF = s.webLocaleCSRF(w, r)
data.AllowRegistration = s.cfg.Web.AllowRegistration
data.Version = Version
data.BuildCommit = BuildCommit
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())
if status.Status != "ok" {
s.renderPageStatus(w, r, "unavailable", webPage{Title: "home.unavailableTitle", Heading: "home.unavailableTitle", Message: "home.unavailableMessage"}, http.StatusServiceUnavailable)
return
}
s.renderPage(w, r, "home", webPage{Title: "home.title", Status: status.Status})
}
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
}
if !s.verifyWebLocaleCSRF(r) {
s.renderPageStatus(w, r, "error", webPage{Title: "error.label", Heading: "error.badRequest", Message: "error.tryAgain", BackURL: "/"}, http.StatusForbidden)
return
}
locale := r.FormValue("locale")
if locale != "ru" && locale != "en" && locale != "system" {
locale = "system"
}
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 (s *Server) handleConfirmResult(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: "confirm.resultTitle", Heading: "confirm.resultTitle", Message: "confirm.resultMessage", 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)
})
}