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