feat(web): complete localized sync server console

This commit is contained in:
2026-07-17 07:02:11 +08:00
parent 0014910638
commit e8605f15a2
41 changed files with 1494 additions and 162 deletions
+4 -1
View File
@@ -22,9 +22,12 @@ func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/login")
return
}
if !s.requirePublicWebMutation(w, r, "/admin/login") {
return
}
user := r.FormValue("username")
pass := r.FormValue("password")
if !s.allowRate(w, r, "login", user) {
if !s.allowWebRate(w, r, "login", user, "/admin/login") {
return
}
if !s.cfg.CheckAdmin(user, pass) {
+1 -1
View File
@@ -15,7 +15,7 @@ import (
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
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)
s.renderPageStatus(w, r, "error", webPage{Title: "error.notFound", Heading: "error.notFound", Message: "error.notFound", BackURL: "/"}, http.StatusNotFound)
return
}
jsonErr(w, 404, "not found")
+3
View File
@@ -102,6 +102,9 @@ func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
}
tokenStr = req.Token
} else if err := r.ParseForm(); err == nil {
if !s.requirePublicWebMutation(w, r, "/login") {
return
}
tokenStr = r.FormValue("token")
} else {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "invalid form")
+47 -12
View File
@@ -37,12 +37,15 @@ func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/register")
return
}
if !s.requirePublicWebMutation(w, r, "/register") {
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, "register", webPage{Title: "auth.registerTitle", Flash: "error.allFieldsRequired"})
return
}
if !s.allowRate(w, r, "register", email) {
if !s.allowWebRate(w, r, "register", email, "/register") {
return
}
if err := validatePassword(password); err != "" {
@@ -104,12 +107,15 @@ func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/forgot")
return
}
if !s.requirePublicWebMutation(w, r, "/forgot") {
return
}
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
if email == "" {
s.renderPage(w, r, "forgot", webPage{Title: "auth.forgotTitle", Flash: "error.emailRequired"})
return
}
if !s.allowRate(w, r, "forgot", email) {
if !s.allowWebRate(w, r, "forgot", email, "/forgot") {
return
}
var userID string
@@ -163,12 +169,15 @@ func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/forgot")
return
}
if !s.requirePublicWebMutation(w, r, "/forgot") {
return
}
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", "") {
if !s.allowWebRate(w, r, "reset", "", "/forgot") {
return
}
if err := validatePassword(password); err != "" {
@@ -205,8 +214,11 @@ func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login")
return
}
if !s.requirePublicWebMutation(w, r, "/login") {
return
}
login, password := strings.TrimSpace(r.FormValue("username")), r.FormValue("password")
if !s.allowRate(w, r, "login", login) {
if !s.allowWebRate(w, r, "login", login, "/login") {
return
}
var userID, hash string
@@ -239,11 +251,20 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
return
}
var username, email string
if err := s.db.QueryRow("SELECT username, email FROM server_users WHERE id=?", userID).Scan(&username, &email); err != nil {
var confirmed int
if err := s.db.QueryRow("SELECT username, email, confirmed FROM server_users WHERE id=?", userID).Scan(&username, &email, &confirmed); 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)
query := strings.TrimSpace(r.URL.Query().Get("q"))
where := " WHERE ud.user_id=?"
args := []interface{}{userID}
if query != "" {
like := "%" + query + "%"
where += " AND (d.name LIKE ? OR d.vault_id LIKE ? OR d.client_version LIKE ?)"
args = append(args, like, like, like)
}
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+` ORDER BY d.created_at DESC`, args...)
if err != nil {
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return
@@ -267,11 +288,16 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return
}
flash := r.URL.Query().Get("error")
if flash != "error.invalidCredentials" {
flash := r.URL.Query().Get("flash")
flashError := false
if flash == "" {
flash = r.URL.Query().Get("error")
flashError = flash != ""
}
if flash != "error.invalidCredentials" && flash != "user.deviceRevoked" {
flash = ""
}
s.renderPage(w, r, "dashboard", webPage{Title: "user.account", UserName: username, Email: email, Devices: devices, Flash: flash})
s.renderPage(w, r, "dashboard", webPage{Title: "user.account", UserName: username, Email: email, UserConfirmed: confirmed != 0, Devices: devices, Flash: flash, FlashError: flashError, List: webList{Query: query}})
}
func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) {
@@ -353,12 +379,21 @@ func (s *Server) handleUserWebDeviceAction(w http.ResponseWriter, r *http.Reques
}
password = req.Password
}
if password == "" || !s.allowRate(w, r, "auth-test", session.SubjectID) {
if password == "" {
if password == "" {
if formRequest {
http.Redirect(w, r, "/dashboard?error=error.invalidCredentials", http.StatusSeeOther)
} else {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "password required")
}
return
}
if formRequest {
if !s.allowWebRate(w, r, "auth-test", session.SubjectID, "/dashboard") {
return
}
} else if !s.allowRate(w, r, "auth-test", session.SubjectID) {
return
}
var hash string
if err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", session.SubjectID).Scan(&hash); err != nil {
jsonInternalError(w, err)
@@ -387,7 +422,7 @@ func (s *Server) handleUserWebDeviceAction(w http.ResponseWriter, r *http.Reques
}
s.auditLog("device_revoked", session.SubjectID, deviceID, s.clientIP(r), "device revoked from web dashboard")
if formRequest {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
http.Redirect(w, r, "/dashboard?flash=user.deviceRevoked", http.StatusSeeOther)
return
}
jsonOK(w, map[string]string{"status": "revoked"})
+50 -8
View File
@@ -6,6 +6,7 @@ import (
"io/fs"
"os"
"path/filepath"
"strings"
"time"
)
@@ -64,33 +65,65 @@ func (s *Server) blobStorageWritable() bool {
// ServerStats is intentionally independent from the web UI so a future
// admin panel can expose operational data without coupling to templates.
type ServerStats struct {
Users int `json:"users"`
ActiveDevices int `json:"active_devices"`
RevokedDevices int `json:"revoked_devices"`
Vaults int `json:"vaults"`
Operations int `json:"operations"`
DatabaseBytes int64 `json:"database_bytes"`
BlobBytes int64 `json:"blob_bytes"`
LastSyncAt string `json:"last_sync_activity"`
Users int `json:"users"`
ActiveUsers int `json:"active_users"`
BlockedUsers int `json:"blocked_users"`
UnconfirmedUsers int `json:"unconfirmed_users"`
ActiveDevices int `json:"active_devices"`
RevokedDevices int `json:"revoked_devices"`
Vaults int `json:"vaults"`
Operations int `json:"operations"`
Operations24h int `json:"operations_24h"`
Blobs int `json:"blobs"`
BlobReferences int `json:"blob_references"`
OrphanBlobs int `json:"orphan_blobs"`
TempUploads int `json:"temp_uploads"`
ExpiredSessions int `json:"expired_sessions"`
ExpiredTokens int `json:"expired_email_tokens"`
AuditEvents int `json:"audit_events"`
DatabaseBytes int64 `json:"database_bytes"`
BlobBytes int64 `json:"blob_bytes"`
LastSyncAt string `json:"last_sync_activity"`
LastCleanupAt string `json:"last_cleanup_at"`
}
func (s *Server) Stats(ctx context.Context) (ServerStats, error) {
var stats ServerStats
now := time.Now().UTC()
queries := []struct {
query string
target *int
}{
{"SELECT COUNT(*) FROM server_users", &stats.Users},
{"SELECT COUNT(*) FROM server_users WHERE confirmed=1 AND blocked=0", &stats.ActiveUsers},
{"SELECT COUNT(*) FROM server_users WHERE blocked=1", &stats.BlockedUsers},
{"SELECT COUNT(*) FROM server_users WHERE confirmed=0", &stats.UnconfirmedUsers},
{"SELECT COUNT(*) FROM server_devices WHERE COALESCE(revoked_at, '') = ''", &stats.ActiveDevices},
{"SELECT COUNT(*) FROM server_devices WHERE COALESCE(revoked_at, '') != ''", &stats.RevokedDevices},
{"SELECT COUNT(DISTINCT user_id || ':' || vault_id) FROM server_devices WHERE COALESCE(user_id,'') != '' AND COALESCE(vault_id,'') != ''", &stats.Vaults},
{"SELECT COUNT(*) FROM server_ops", &stats.Operations},
{"SELECT COUNT(*) FROM server_blobs", &stats.Blobs},
{"SELECT COUNT(*) FROM server_blob_refs", &stats.BlobReferences},
{"SELECT COUNT(*) FROM server_blobs b WHERE NOT EXISTS (SELECT 1 FROM server_blob_refs r WHERE r.sha256=b.sha256)", &stats.OrphanBlobs},
{"SELECT COUNT(*) FROM server_audit_log", &stats.AuditEvents},
}
for _, query := range queries {
if err := s.db.QueryRowContext(ctx, query.query).Scan(query.target); err != nil {
return ServerStats{}, err
}
}
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_ops WHERE created_at >= ?", now.Add(-24*time.Hour).Format(time.RFC3339)).Scan(&stats.Operations24h); err != nil {
return ServerStats{}, err
}
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_sessions WHERE expires_at <= ?", now.Format(time.RFC3339)).Scan(&stats.ExpiredSessions); err != nil {
return ServerStats{}, err
}
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_email_tokens WHERE expires_at <= ?", now.Format(time.RFC3339)).Scan(&stats.ExpiredTokens); err != nil {
return ServerStats{}, err
}
if err := s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(created_at),'') FROM server_audit_log WHERE event_type='retention_cleanup'").Scan(&stats.LastCleanupAt); err != nil {
return ServerStats{}, err
}
if err := s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(last_seen), '') FROM server_devices").Scan(&stats.LastSyncAt); err != nil {
return ServerStats{}, err
}
@@ -112,5 +145,14 @@ func (s *Server) Stats(ctx context.Context) (ServerStats, error) {
}); err != nil && !os.IsNotExist(err) {
return ServerStats{}, fmt.Errorf("blob storage stats: %w", err)
}
if entries, err := os.ReadDir(s.blobsDir); err == nil {
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".upload-") {
stats.TempUploads++
}
}
} else if !os.IsNotExist(err) {
return ServerStats{}, fmt.Errorf("temporary upload stats: %w", err)
}
return stats, nil
}
+160 -2
View File
@@ -77,6 +77,12 @@ var _translations = map[string]map[string]string{
"error.accountTaken": "Имя пользователя или email уже заняты.",
"error.emailRequired": "Введите email.",
"error.passwordMismatch": "Пароли не совпадают.",
"error.invalidPublicURL": "Укажите корректный абсолютный public URL с http или https.",
"error.rateLimited": "Слишком много попыток. Подождите и повторите запрос.",
"error.notFound": "Страница не найдена.",
"error.deviceMustBeRevoked": "Удалять можно только предварительно отозванное устройство.",
"home.unavailableTitle": "Сервер временно недоступен",
"home.unavailableMessage": "Сервер ещё не готов обслуживать запросы. Повторите попытку позже.",
"error.invalidCredentials": "Неверное имя пользователя или пароль.",
"error.internal": "Внутренняя ошибка. Повторите попытку позже.",
"admin.eyebrow": "Администрирование сервера",
@@ -86,7 +92,7 @@ var _translations = map[string]map[string]string{
"admin.access": "Доступ",
"admin.activeDevices": "Активные устройства",
"admin.operations": "Операции",
"admin.vaults": "Vault",
"admin.vaults": "Хранилища",
"admin.storage": "Хранилище",
"admin.audit": "Аудит",
"admin.settings": "Настройки",
@@ -122,11 +128,17 @@ var _translations = map[string]map[string]string{
"admin.smtpConfigured": "SMTP настраивается отдельно и пароль никогда не возвращается в форму.",
"user.account": "Моя учётная запись",
"user.devices": "Подключённые устройства",
"user.emailConfirmed": "Email подтверждён",
"user.emailUnconfirmed": "Email не подтверждён",
"user.filterDevices": "Поиск устройств",
"user.connectInstruction": "Подключите новое устройство через приложение Verstak: откройте настройки синхронизации и выполните pairing с этим сервером.",
"user.deviceRevoked": "Устройство отозвано.",
"user.noDevices": "Устройств пока нет",
"device.name": "Устройство",
"device.vault": "Vault",
"device.vault": "Хранилище",
"device.version": "Версия клиента",
"device.lastSeen": "Последняя активность",
"device.created": "Подключено",
"device.status": "Статус",
"device.active": "Активно",
"device.revoked": "Отозвано",
@@ -227,10 +239,71 @@ var _translations = map[string]map[string]string{
"admin.smtpPassed": "✓ Тест пройден",
"admin.smtpRequired": "Укажите SMTP-сервер, порт и отправителя.",
"admin.smtpTestFailed": "Не удалось проверить настройки SMTP. Повторите попытку.",
"admin.settingsSaved": "Настройки сервера сохранены.",
"admin.smtpSaved": "Настройки SMTP сохранены.",
"admin.smtpStartTLS": "STARTTLS",
"admin.smtpTLS": "TLS",
"admin.warningReadiness": "Один или несколько компонентов сервера не готовы к работе.",
"admin.warningSMTP": "SMTP не настроен: письма подтверждения и сброса пароля не будут отправляться.",
"admin.warningOperations": "Журнал операций быстро растёт. Не удаляйте его без checkpoint-механизма.",
"admin.ops24h": "за 24 часа",
"admin.sort": "Сортировка",
"admin.created": "Создан",
"admin.ip": "IP",
"admin.message": "Сообщение",
"admin.severity": "Важность",
"admin.info": "Информация",
"admin.warningLevel": "Предупреждение",
"admin.errorLevel": "Ошибка",
"admin.publicURL": "Публичный URL",
"admin.saveSettings": "Сохранить настройки",
"admin.trustedProxies": "Доверенные reverse proxy",
"admin.network": "Сеть",
"admin.limits": "Лимиты и retention",
"admin.readOnlyConfig": "Лимиты задаются конфигурацией сервера и доступны здесь только для просмотра.",
"admin.maxJSONBody": "Максимальный JSON body",
"admin.maxPushOperations": "Операций в push",
"admin.maxPullPage": "Операций в pull page",
"admin.maxBlobBytes": "Максимальный размер blob",
"admin.maxVaultBlobBytes": "Квота blob vault",
"admin.maxUserBlobBytes": "Квота blob пользователя",
"admin.sequence": "Последняя sequence",
"admin.refresh": "Обновить",
"admin.copyDiagnostics": "Скопировать диагностику",
"admin.copied": "Скопировано",
"admin.blobReferences": "Ссылки на blobs",
"admin.orphanBlobs": "Blob без ссылок",
"admin.tempUploads": "Временные загрузки",
"admin.expiredSessions": "Истёкшие сессии",
"admin.expiredTokens": "Истёкшие email-токены",
"admin.lastCleanup": "Последняя очистка",
"admin.cleanupDone": "Безопасная очистка завершена.",
"audit.other": "Другое событие",
"audit.deviceAuthFailed": "Ошибка авторизации устройства",
"audit.devicePaired": "Устройство подключено",
"audit.deviceRevoked": "Устройство отозвано",
"audit.deviceDeleted": "Отозванное устройство удалено",
"audit.rateLimited": "Сработало ограничение попыток",
"audit.retentionCleanup": "Выполнена безопасная очистка",
"audit.smtpSettingsUpdated": "Настройки SMTP изменены",
"audit.smtpTestFailed": "Проверка SMTP не удалась",
"audit.smtpTestPassed": "Проверка SMTP прошла",
"audit.userBlockChanged": "Статус блокировки пользователя изменён",
"audit.userConfirmed": "Email пользователя подтверждён",
"audit.userCreated": "Пользователь создан",
"audit.userDeleted": "Пользователь удалён",
"audit.userPasswordReset": "Пароль пользователя сброшен",
"audit.userUpdated": "Пользователь изменён",
"audit.webSettingsUpdated": "Общие настройки изменены",
"status.ok": "Работает",
"status.degraded": "Требует внимания",
"status.available": "Доступно",
"status.unavailable": "Недоступно",
"admin.revokeConfirm": "Вы уверены?",
"common.loading": "Загрузка...",
"common.ok": "OK",
"common.error": "Ошибка",
"common.warning": "Предупреждения",
"admin.filterPlaceholder": "Поиск...",
"admin.email": "Email",
"admin.actions": "Действия",
@@ -249,8 +322,14 @@ var _translations = map[string]map[string]string{
"admin.noUsers": "Нет пользователей",
"admin.resetPasswordConfirm": "Сбросить пароль",
"admin.resetPasswordMessage": "Новый пароль: ",
"admin.generatePassword": "Сгенерировать одноразовый пароль",
"admin.oneTimePasswordNotice": "Сохраните этот пароль сейчас: он будет показан только один раз.",
"admin.oneTimePasswordHint": "После ухода со страницы получить пароль повторно нельзя. Пользователь должен сменить его после входа.",
"admin.resetBtn": "Сбросить",
"admin.deleteUser": "Удалить",
"admin.deleteDevice": "Удалить устройство",
"admin.confirmUser": "Подтвердить email",
"admin.deleteUserConfirm": "Удалить пользователя и все связанные устройства, vault-связи, blobs и операции? Это действие нельзя отменить.",
"admin.deleteUserMessage": "Удалить пользователя %s?",
"admin.deleteBtn": "Удалить",
"admin.unblockUserTitle": "Разблокировать",
@@ -320,6 +399,12 @@ var _translations = map[string]map[string]string{
"error.accountTaken": "Username or email is already in use.",
"error.emailRequired": "Enter an email address.",
"error.passwordMismatch": "Passwords do not match.",
"error.invalidPublicURL": "Enter a valid absolute public URL using http or https.",
"error.rateLimited": "Too many attempts. Wait and try again.",
"error.notFound": "Page not found.",
"error.deviceMustBeRevoked": "Only a revoked device can be deleted.",
"home.unavailableTitle": "Server is temporarily unavailable",
"home.unavailableMessage": "The server is not ready to serve requests yet. Try again later.",
"error.invalidCredentials": "Invalid username or password.",
"error.internal": "Internal error. Please try again later.",
"admin.eyebrow": "Server administration",
@@ -365,11 +450,17 @@ var _translations = map[string]map[string]string{
"admin.smtpConfigured": "SMTP is configured separately and its password is never returned to a form.",
"user.account": "My account",
"user.devices": "Connected devices",
"user.emailConfirmed": "Email confirmed",
"user.emailUnconfirmed": "Email not confirmed",
"user.filterDevices": "Search devices",
"user.connectInstruction": "Connect another device in Verstak: open sync settings and pair it with this server.",
"user.deviceRevoked": "Device revoked.",
"user.noDevices": "No devices yet",
"device.name": "Device",
"device.vault": "Vault",
"device.version": "Client version",
"device.lastSeen": "Last activity",
"device.created": "Connected",
"device.status": "Status",
"device.active": "Active",
"device.revoked": "Revoked",
@@ -470,10 +561,71 @@ var _translations = map[string]map[string]string{
"admin.smtpPassed": "✓ Test passed",
"admin.smtpRequired": "Enter the SMTP server, port, and sender.",
"admin.smtpTestFailed": "Could not test the SMTP settings. Please try again.",
"admin.settingsSaved": "Server settings saved.",
"admin.smtpSaved": "SMTP settings saved.",
"admin.smtpStartTLS": "STARTTLS",
"admin.smtpTLS": "TLS",
"admin.warningReadiness": "One or more server components are not ready.",
"admin.warningSMTP": "SMTP is not configured: confirmation and password-reset emails will not be sent.",
"admin.warningOperations": "The operation log is growing quickly. Do not prune it without a checkpoint mechanism.",
"admin.ops24h": "in the last 24 hours",
"admin.sort": "Sort",
"admin.created": "Created",
"admin.ip": "IP",
"admin.message": "Message",
"admin.severity": "Severity",
"admin.info": "Info",
"admin.warningLevel": "Warning",
"admin.errorLevel": "Error",
"admin.publicURL": "Public URL",
"admin.saveSettings": "Save settings",
"admin.trustedProxies": "Trusted reverse proxies",
"admin.network": "Network",
"admin.limits": "Limits and retention",
"admin.readOnlyConfig": "Limits are configured on the server and are read-only here.",
"admin.maxJSONBody": "Maximum JSON body",
"admin.maxPushOperations": "Operations per push",
"admin.maxPullPage": "Operations per pull page",
"admin.maxBlobBytes": "Maximum blob size",
"admin.maxVaultBlobBytes": "Vault blob quota",
"admin.maxUserBlobBytes": "User blob quota",
"admin.sequence": "Latest sequence",
"admin.refresh": "Refresh",
"admin.copyDiagnostics": "Copy diagnostics",
"admin.copied": "Copied",
"admin.blobReferences": "Blob references",
"admin.orphanBlobs": "Unreferenced blobs",
"admin.tempUploads": "Temporary uploads",
"admin.expiredSessions": "Expired sessions",
"admin.expiredTokens": "Expired email tokens",
"admin.lastCleanup": "Last cleanup",
"admin.cleanupDone": "Safe cleanup completed.",
"audit.other": "Other event",
"audit.deviceAuthFailed": "Device authentication failed",
"audit.devicePaired": "Device paired",
"audit.deviceRevoked": "Device revoked",
"audit.deviceDeleted": "Revoked device deleted",
"audit.rateLimited": "Rate limit triggered",
"audit.retentionCleanup": "Safe cleanup completed",
"audit.smtpSettingsUpdated": "SMTP settings updated",
"audit.smtpTestFailed": "SMTP test failed",
"audit.smtpTestPassed": "SMTP test passed",
"audit.userBlockChanged": "User block status changed",
"audit.userConfirmed": "User email confirmed",
"audit.userCreated": "User created",
"audit.userDeleted": "User deleted",
"audit.userPasswordReset": "User password reset",
"audit.userUpdated": "User updated",
"audit.webSettingsUpdated": "General settings updated",
"status.ok": "Operational",
"status.degraded": "Needs attention",
"status.available": "Available",
"status.unavailable": "Unavailable",
"admin.revokeConfirm": "Are you sure?",
"common.loading": "Loading...",
"common.ok": "OK",
"common.error": "Error",
"common.warning": "Warnings",
"admin.filterPlaceholder": "Search...",
"admin.email": "Email",
"admin.actions": "Actions",
@@ -492,8 +644,14 @@ var _translations = map[string]map[string]string{
"admin.noUsers": "No users",
"admin.resetPasswordConfirm": "Reset Password",
"admin.resetPasswordMessage": "New password: ",
"admin.generatePassword": "Generate one-time password",
"admin.oneTimePasswordNotice": "Save this password now: it is shown only once.",
"admin.oneTimePasswordHint": "It cannot be retrieved after leaving this page. The user should change it after signing in.",
"admin.resetBtn": "Reset",
"admin.deleteUser": "Delete",
"admin.deleteDevice": "Delete device",
"admin.confirmUser": "Confirm email",
"admin.deleteUserConfirm": "Delete this user and all related devices, vault associations, blobs, and operations? This cannot be undone.",
"admin.deleteUserMessage": "Delete user %s?",
"admin.deleteBtn": "Delete",
"admin.unblockUserTitle": "Unblock",
+29 -8
View File
@@ -18,12 +18,12 @@ var ratePolicies = map[string]RatePolicy{
"admin-reset": {Limit: 8, Window: time.Hour},
}
// allowRate applies an IP limit and, where a login/account is supplied, an
// additional bounded account bucket. It never logs submitted credentials.
func (s *Server) allowRate(w http.ResponseWriter, r *http.Request, action, account string) bool {
// rateRetryAfter applies an IP limit and, where a login/account is supplied,
// an additional bounded account bucket. It never logs submitted credentials.
func (s *Server) rateRetryAfter(r *http.Request, action, account string) (int, bool) {
policy, ok := ratePolicies[action]
if !ok {
return true
return 0, true
}
ip := s.clientIP(r)
s.limiter.Cleanup(2 * policy.Window)
@@ -40,13 +40,34 @@ func (s *Server) allowRate(w http.ResponseWriter, r *http.Request, action, accou
if seconds < 1 {
seconds = 1
}
w.Header().Set("Retry-After", strconvItoa(seconds))
s.auditLog("rate_limit_exceeded", "", "", ip, "rate limit: "+action)
jsonErr(w, http.StatusTooManyRequests, "too many attempts")
return false
return seconds, false
}
}
return true
return 0, true
}
// allowRate writes API-compatible JSON for transport endpoints.
func (s *Server) allowRate(w http.ResponseWriter, r *http.Request, action, account string) bool {
retryAfter, allowed := s.rateRetryAfter(r, action, account)
if allowed {
return true
}
w.Header().Set("Retry-After", strconvItoa(retryAfter))
jsonErr(w, http.StatusTooManyRequests, "too many attempts")
return false
}
// allowWebRate is the HTML counterpart of allowRate. Browser forms receive a
// localized error page instead of a machine-readable API error.
func (s *Server) allowWebRate(w http.ResponseWriter, r *http.Request, action, account, back string) bool {
retryAfter, allowed := s.rateRetryAfter(r, action, account)
if allowed {
return true
}
w.Header().Set("Retry-After", strconvItoa(retryAfter))
s.renderWebError(w, r, http.StatusTooManyRequests, "error.rateLimited", back)
return false
}
func strconvItoa(value int) string {
+1
View File
@@ -37,6 +37,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/admin", s.handleAdminRoot)
s.mux.HandleFunc("/admin/logout", s.handleAdminWebLogout)
s.mux.HandleFunc("/admin/action", s.handleAdminWebAction)
s.mux.HandleFunc("/admin/password-result", s.handleAdminPasswordResult)
s.mux.HandleFunc("/admin/dashboard", s.handleAdminWeb)
s.mux.HandleFunc("/admin/users", s.handleAdminWeb)
s.mux.HandleFunc("/admin/create-user", s.handleAdminCreateUserWeb)
+19 -15
View File
@@ -8,20 +8,23 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "github.com/mattn/go-sqlite3"
)
type Server struct {
db *sql.DB
dbPath string
cfg *Config
blobsDir string
mux *http.ServeMux
limiter *rateLimiter
web *webRenderer
startedAt time.Time
db *sql.DB
dbPath string
cfg *Config
blobsDir string
mux *http.ServeMux
limiter *rateLimiter
web *webRenderer
startedAt time.Time
secretMu sync.Mutex
webSecrets map[string]oneTimeWebSecret
}
// Version and BuildCommit are assigned through -ldflags during release builds.
@@ -89,13 +92,14 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
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(),
db: db,
dbPath: dbPath,
cfg: cfg,
blobsDir: blobsDir,
limiter: newRateLimiter(nil),
web: web,
startedAt: time.Now().UTC(),
webSecrets: make(map[string]oneTimeWebSecret),
}
s.mux = http.NewServeMux()
return s, nil
+5 -3
View File
@@ -640,12 +640,14 @@ func pairSyncDevice(t *testing.T, serverURL, username, password, vaultID string)
func postWebReset(t *testing.T, s *Server, token, password string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{
"token": {token},
"password": {password},
"confirm": {password},
"token": {token},
"password": {password},
"confirm": {password},
"locale_csrf": {"test-public-csrf"},
}
request := httptest.NewRequest(http.MethodPost, "/reset", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "test-public-csrf"})
response := httptest.NewRecorder()
s.mux.ServeHTTP(response, request)
return response
+8 -2
View File
@@ -26,17 +26,22 @@ body { min-width:320px; margin:0; background:var(--bg); color:var(--text); font:
: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; }
.stack input:not([type="checkbox"]),.stack select,.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; }
.check-field { display:flex !important; align-items:center; gap:.55rem; }.check-field input[type="checkbox"] { width:1.1rem; height:1.1rem; margin:0; accent-color:var(--accent); }
.flash { padding:.75rem; border-radius:8px; }
.flash.error { border:1px solid #6e343b; background:#3a2025; color:#ffd9d9; }
.flash.success { border:1px solid #286052; background:#173d36; color:#a5f1df; }
.one-time-secret { display:block; overflow-wrap:anywhere; padding:.8rem; border:1px solid var(--accent); border-radius:.55rem; background:#0c171d; color:var(--accent); font-size:1.1rem; }
.warning { border-color:#6b5b28; background:#302a16; color:#ffedb0; }
.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; }
table { width:100%; min-width:720px; 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; }
.badge.warning { background:#4b421d; color:#ffed9a; }
.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; }
@@ -49,4 +54,5 @@ details { margin-top:.6rem; } summary { cursor:pointer; color:var(--accent); }.c
.list-filter,.pager { display:flex; flex-wrap:wrap; align-items:end; gap:.65rem; margin:0 0 1rem; }.list-filter label { display:grid; gap:.3rem; color:var(--muted); }.list-filter input,.list-filter select { min-height:38px; padding:.4rem .55rem; border:1px solid var(--line); border-radius:7px; background:#0d151a; color:var(--text); }.pager { justify-content:flex-end; align-items:center; }
.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; }
.confirm-dialog { width:min(calc(100% - 2rem),420px); margin:auto; padding:1.25rem; border:1px solid var(--line); border-radius:14px; background:var(--surface); color:var(--text); box-shadow:0 24px 64px #0009; }.confirm-dialog::backdrop { background:#05080bb8; }.confirm-dialog h2,.confirm-dialog p { margin: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; } }
+32 -1
View File
@@ -1,7 +1,38 @@
document.addEventListener("change", function (event) {
if (event.target.matches("[data-auto-submit]")) event.target.form.requestSubmit();
});
const confirmationDialog = document.getElementById("confirm-dialog");
let confirmationForm = null;
let confirmationButton = null;
document.addEventListener("click", function (event) {
const button = event.target.closest("[data-confirm]");
if (button && !window.confirm(button.dataset.confirm)) event.preventDefault();
if (!button || !button.form || !confirmationDialog) return;
event.preventDefault();
confirmationForm = button.form;
confirmationButton = button;
document.getElementById("confirm-dialog-message").textContent = button.dataset.confirm;
confirmationDialog.showModal();
});
if (confirmationDialog) {
confirmationDialog.addEventListener("close", function () {
if (confirmationDialog.returnValue === "confirm" && confirmationForm) {
confirmationForm.requestSubmit(confirmationButton);
}
confirmationForm = null;
confirmationButton = null;
});
}
document.addEventListener("click", async function (event) {
const button = event.target.closest("[data-copy-diagnostics]");
if (!button || !navigator.clipboard) return;
try {
const response = await fetch(button.dataset.copyDiagnostics, { credentials: "same-origin" });
if (!response.ok) return;
await navigator.clipboard.writeText(await response.text());
button.textContent = button.dataset.copiedLabel;
} catch (_) {
// The downloadable JSON link remains available when clipboard access is unavailable.
}
});
-29
View File
@@ -1,29 +0,0 @@
{{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 or (eq .AdminPage "users") (or (eq .AdminPage "devices") (eq .AdminPage "audit"))}}<form class="list-filter" method="get" action="{{.CurrentPath}}"><label>{{t .Locale "admin.search"}}<input name="q" value="{{.List.Query}}" placeholder="{{t .Locale "admin.search"}}"></label><label>{{t .Locale "device.status"}}<select name="status"><option value="">{{t .Locale "admin.all"}}</option><option value="active" {{if eq .List.Status "active"}}selected{{end}}>{{t .Locale "device.active"}}</option><option value="blocked" {{if eq .List.Status "blocked"}}selected{{end}}>{{t .Locale "admin.blocked"}}</option><option value="revoked" {{if eq .List.Status "revoked"}}selected{{end}}>{{t .Locale "device.revoked"}}</option><option value="unconfirmed" {{if eq .List.Status "unconfirmed"}}selected{{end}}>{{t .Locale "admin.unconfirmed"}}</option></select></label><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form>{{end}}
{{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"><a href="/admin/vault/?user={{.UserID}}&amp;vault={{.Vault}}">{{short .Vault 24}}</a></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><form method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="cleanup"><input name="password" type="password" required placeholder="{{t .Locale "field.password"}}"><button class="button secondary" type="submit">{{t .Locale "admin.runCleanup"}}</button></form>
{{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><div class="actions"><a class="button secondary" href="/api/v1/health">{{t .Locale "admin.healthJSON"}}</a><a class="button secondary" href="/admin/diagnostics.json">{{t .Locale "admin.downloadDiagnostics"}}</a></div></section>{{end}}
{{if or (eq .AdminPage "users") (or (eq .AdminPage "devices") (eq .AdminPage "audit"))}}<nav class="pager" aria-label="{{t .Locale "admin.pagination"}}"><span>{{.List.Total}}</span>{{if .List.Previous}}<a class="button secondary" href="{{.CurrentPath}}?q={{.List.Query}}&amp;status={{.List.Status}}&amp;page={{.List.Previous}}">{{t .Locale "admin.previous"}}</a>{{end}}<span>{{.List.Page}} / {{.List.Pages}}</span>{{if .List.Next}}<a class="button secondary" href="{{.CurrentPath}}?q={{.List.Query}}&amp;status={{.List.Status}}&amp;page={{.List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>{{end}}
</div>
</section>
{{end}}
@@ -0,0 +1,9 @@
{{define "admin_audit"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.audit"}}</h1>
<form class="list-filter" method="get" action="{{.CurrentPath}}"><label>{{t .Locale "admin.search"}}<input name="q" value="{{.List.Query}}" maxlength="160" placeholder="{{t .Locale "admin.search"}}"></label><label>{{t .Locale "admin.event"}}<input name="event" value="{{.List.Event}}" maxlength="160"></label><label>{{t .Locale "admin.user"}}<input name="user" value="{{.List.User}}" maxlength="160"></label><label>{{t .Locale "admin.severity"}}<select name="severity"><option value="">{{t .Locale "admin.all"}}</option><option value="info" {{if eq .List.Severity "info"}}selected{{end}}>{{t .Locale "admin.info"}}</option><option value="warning" {{if eq .List.Severity "warning"}}selected{{end}}>{{t .Locale "admin.warningLevel"}}</option><option value="error" {{if eq .List.Severity "error"}}selected{{end}}>{{t .Locale "admin.errorLevel"}}</option></select></label><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form>
<section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.time"}}</th><th>{{t .Locale "admin.event"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.ip"}}</th><th>{{t .Locale "admin.severity"}}</th><th>{{t .Locale "admin.message"}}</th></tr></thead><tbody>{{range .Audit}}<tr><td>{{webtime $.Locale .At}}</td><td>{{auditlabel $.Locale .Event}}</td><td>{{.User}}</td><td>{{.Device}}</td><td>{{.IP}}</td><td><span class="badge {{if eq .Severity "error"}}danger{{else if eq .Severity "warning"}}warning{{else}}ok{{end}}">{{if eq .Severity "error"}}{{t $.Locale "admin.errorLevel"}}{{else if eq .Severity "warning"}}{{t $.Locale "admin.warningLevel"}}{{else}}{{t $.Locale "admin.info"}}{{end}}</span></td><td>{{.Message}}</td></tr>{{else}}<tr><td colspan="7" class="empty">{{t .Locale "admin.noAudit"}}</td></tr>{{end}}</tbody></table></div></section>
<nav class="pager" aria-label="{{t .Locale "admin.pagination"}}"><span>{{.List.Total}}</span>{{if .List.Previous}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Previous}}">{{t .Locale "admin.previous"}}</a>{{end}}<span>{{.List.Page}} / {{.List.Pages}}</span>{{if .List.Next}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>
</div></section>
{{end}}
@@ -0,0 +1,10 @@
{{define "admin_dashboard"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<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"}} · {{.Stats.ActiveUsers}} {{t .Locale "device.active"}}</span></article><article class="card stat"><strong>{{.Stats.ActiveDevices}}</strong><span>{{t .Locale "admin.activeDevices"}} · {{.Stats.RevokedDevices}} {{t .Locale "device.revoked"}}</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"}} · {{.Stats.Operations24h}} {{t .Locale "admin.ops24h"}}</span></article><article class="card stat"><strong>{{webbytes .Stats.DatabaseBytes}}</strong><span>{{t .Locale "admin.databaseBytes"}}</span></article><article class="card stat"><strong>{{webbytes .Stats.BlobBytes}}</strong><span>{{.Stats.Blobs}} {{t .Locale "admin.blobBytes"}}</span></article></div>
{{if .Warnings}}<section class="card panel warning"><h2>{{t .Locale "common.warning"}}</h2>{{range .Warnings}}<p>{{t $.Locale .}}</p>{{end}}</section>{{end}}
<section class="card panel"><h2>{{t .Locale "admin.serviceHealth"}}</h2><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{statuslabel .Locale .Health.Status}}</dd><dt>{{t .Locale "admin.version"}}</dt><dd>{{.Health.Version}} {{.Health.BuildCommit}}</dd><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{webtime .Locale .Stats.LastSyncAt}}</dd><dt>{{t .Locale "admin.serverTime"}}</dt><dd>{{webtime .Locale .Health.ServerTime}}</dd></dl></section>
<section class="card table-card"><h2>{{t .Locale "admin.audit"}}</h2><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.event"}}</th><th>{{t .Locale "admin.time"}}</th></tr></thead><tbody>{{range .Audit}}<tr><td>{{auditlabel $.Locale .Event}}</td><td>{{webtime $.Locale .At}}</td></tr>{{else}}<tr><td colspan="2" class="empty">{{t .Locale "admin.noAudit"}}</td></tr>{{end}}</tbody></table></div></section>
</div></section>
{{end}}
@@ -0,0 +1,9 @@
{{define "admin_devices"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.devices"}}</h1>
<form class="list-filter" method="get" action="{{.CurrentPath}}"><label>{{t .Locale "admin.search"}}<input name="q" value="{{.List.Query}}" maxlength="160" placeholder="{{t .Locale "admin.search"}}"></label><label>{{t .Locale "admin.user"}}<input name="user" value="{{.List.User}}" maxlength="160"></label><label>{{t .Locale "device.vault"}}<input name="vault" value="{{.List.Vault}}" maxlength="160"></label><label>{{t .Locale "device.version"}}<input name="version" value="{{.List.Version}}" maxlength="160"></label><label>{{t .Locale "device.status"}}<select name="status"><option value="">{{t .Locale "admin.all"}}</option><option value="active" {{if eq .List.Status "active"}}selected{{end}}>{{t .Locale "device.active"}}</option><option value="revoked" {{if eq .List.Status "revoked"}}selected{{end}}>{{t .Locale "device.revoked"}}</option></select></label><label>{{t .Locale "admin.sort"}}<select name="sort"><option value="created" {{if eq .List.Sort "created"}}selected{{end}}>{{t .Locale "admin.created"}}</option><option value="name" {{if eq .List.Sort "name"}}selected{{end}}>{{t .Locale "device.name"}}</option><option value="last_seen" {{if eq .List.Sort "last_seen"}}selected{{end}}>{{t .Locale "admin.lastSeen"}}</option></select></label><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form>
<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.version"}}</th><th>{{t .Locale "admin.ip"}}</th><th>{{t .Locale "admin.created"}}</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}}{{if .TokenHint}}<small class="muted">{{.TokenHint}}</small>{{end}}</td><td>{{.User}}</td><td class="mono">{{short .Vault 16}}</td><td>{{.Version}}</td><td>{{.LastIP}}</td><td>{{webtime $.Locale .CreatedAt}}</td><td>{{webtime $.Locale .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" autocomplete="current-password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit" data-confirm="{{t $.Locale "admin.revokeConfirm"}}">{{t $.Locale "device.revoke"}}</button></form>{{else}}<form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="delete-device"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" autocomplete="current-password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit" data-confirm="{{t $.Locale "admin.revokeConfirm"}}">{{t $.Locale "admin.deleteDevice"}}</button></form>{{end}}</td></tr>{{else}}<tr><td colspan="9" class="empty">{{t .Locale "admin.noDevices"}}</td></tr>{{end}}</tbody></table></div></section>
<nav class="pager" aria-label="{{t .Locale "admin.pagination"}}"><span>{{.List.Total}}</span>{{if .List.Previous}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Previous}}">{{t .Locale "admin.previous"}}</a>{{end}}<span>{{.List.Page}} / {{.List.Pages}}</span>{{if .List.Next}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>
</div></section>
{{end}}
@@ -0,0 +1,7 @@
{{define "admin_diagnostics"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<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>{{statuslabel .Locale .Health.Status}}</dd><dt>{{t .Locale "admin.database"}}</dt><dd>{{boollabel .Locale .Health.DatabaseReachable}}</dd><dt>{{t .Locale "admin.blobStorage"}}</dt><dd>{{boollabel .Locale .Health.BlobStorageWritable}}</dd><dt>{{t .Locale "admin.schema"}}</dt><dd>{{.Health.SchemaVersion}}</dd><dt>{{t .Locale "admin.serverTime"}}</dt><dd>{{webtime .Locale .Health.ServerTime}}</dd></dl><div class="actions"><a class="button secondary" href="/admin/diagnostics">{{t .Locale "admin.refresh"}}</a><button class="button secondary" type="button" data-copy-diagnostics="/admin/diagnostics.json" data-copy-label="{{t .Locale "admin.copyDiagnostics"}}" data-copied-label="{{t .Locale "admin.copied"}}">{{t .Locale "admin.copyDiagnostics"}}</button><a class="button secondary" href="/api/v1/health">{{t .Locale "admin.healthJSON"}}</a><a class="button secondary" href="/admin/diagnostics.json">{{t .Locale "admin.downloadDiagnostics"}}</a></div></section>
</div></section>
{{end}}
@@ -1,2 +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}}
{{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"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><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,14 @@
{{define "admin_nav"}}
<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>
{{end}}
@@ -0,0 +1,8 @@
{{define "admin_password_result"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content narrow-content">
<p class="eyebrow">{{t .Locale "admin.resultTitle"}}</p><h1>{{t .Locale "admin.resetPassword"}}</h1>
<section class="card panel warning"><p>{{t .Locale "admin.oneTimePasswordNotice"}}</p><code class="one-time-secret">{{.OneTimeSecret}}</code><p>{{t .Locale "admin.oneTimePasswordHint"}}</p></section>
<a class="button secondary" href="/admin/users">{{t .Locale "admin.users"}}</a>
</div></section>
{{end}}
@@ -1,2 +1,47 @@
{{define "admin_settings"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="admin-shell"><div class="admin-content"><p class="eyebrow">{{t .Locale "admin.settings"}}</p><h1>{{t .Locale "admin.settings"}}</h1><section class="card panel"><h2>{{t .Locale "admin.general"}}</h2><form method="post" action="/admin/action" class="stack"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="web-settings"><label>{{t .Locale "admin.serverName"}}<input name="server_name" value="{{.ServerName}}"></label><label>{{t .Locale "locale.label"}}<select name="default_locale"><option value="en" {{if eq .DefaultLocale "en"}}selected{{end}}>English</option><option value="ru" {{if eq .DefaultLocale "ru"}}selected{{end}}>Русский</option></select></label><label><input name="allow_registration" type="checkbox" {{if .AllowRegistration}}checked{{end}}>{{t .Locale "admin.allowRegistration"}}</label><label>{{t .Locale "field.password"}}<input name="password" type="password" required></label><button class="button primary">{{t .Locale "admin.saveUser"}}</button></form></section><section class="card panel"><h2>{{t .Locale "admin.smtpTitle"}}</h2><p class="muted">{{t .Locale "admin.smtpConfigured"}}</p><a class="button secondary" href="/admin/dashboard">{{t .Locale "common.back"}}</a></section></div></section>{{end}}
{{define "content"}}
<section class="admin-shell">
{{template "admin_nav" .}}
<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.settings"}}</p>
<h1>{{t .Locale "admin.settings"}}</h1>
{{if .Flash}}<p class="flash success" role="status">{{t .Locale .Flash}}</p>{{end}}
<section class="card panel">
<h2>{{t .Locale "admin.general"}}</h2>
<form method="post" action="/admin/action" class="stack">
<input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="web-settings">
<label>{{t .Locale "admin.serverName"}}<input name="server_name" value="{{.ServerName}}"></label>
<label>{{t .Locale "admin.publicURL"}}<input name="public_url" type="url" value="{{.PublicURL}}" placeholder="https://sync.example.test"></label>
<label>{{t .Locale "locale.label"}}<select name="default_locale"><option value="en" {{if eq .DefaultLocale "en"}}selected{{end}}>English</option><option value="ru" {{if eq .DefaultLocale "ru"}}selected{{end}}>Русский</option></select></label>
<label class="check-field"><input name="allow_registration" type="checkbox" {{if .AllowRegistration}}checked{{end}}>{{t .Locale "admin.allowRegistration"}}</label>
<label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label>
<button class="button primary">{{t .Locale "admin.saveSettings"}}</button>
</form>
</section>
<section class="card panel"><h2>{{t .Locale "admin.network"}}</h2><dl class="details"><dt>{{t .Locale "admin.trustedProxies"}}</dt><dd>{{if .TrustedProxies}}{{.TrustedProxies}}{{else}}—{{end}}</dd></dl></section>
<section class="card panel">
<h2>{{t .Locale "admin.smtpTitle"}}</h2>
<p class="muted">{{t .Locale "admin.smtpConfigured"}}</p>
<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}}" autocomplete="off"></label>
<label>{{t .Locale "admin.smtpPort"}}<input name="smtp_port" inputmode="numeric" value="{{.SMTP.Port}}"></label>
<label>{{t .Locale "admin.smtpUsername"}}<input name="smtp_user" value="{{.SMTP.User}}" autocomplete="username"></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}}>{{t .Locale "admin.smtpStartTLS"}}</option><option value="tls" {{if eq .SMTP.Security "tls"}}selected{{end}}>{{t .Locale "admin.smtpTLS"}}</option></select></label>
<label>{{t .Locale "admin.smtpFrom"}}<input name="smtp_from" type="email" value="{{.SMTP.From}}"></label>
<label>{{t .Locale "admin.smtpServerURL"}}<input name="server_url" type="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>
<form method="post" action="/admin/action" class="inline-form top-gap">
<input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="smtp-test">
<label>{{t .Locale "field.email"}}<input name="test_to" type="email" placeholder="{{.SMTP.From}}"></label>
<label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label>
<button class="button secondary" type="submit">{{t .Locale "admin.smtpTest"}}</button>
</form>
</section>
<section class="card panel"><h2>{{t .Locale "admin.limits"}}</h2><p class="muted">{{t .Locale "admin.readOnlyConfig"}}</p><dl class="details"><dt>{{t .Locale "admin.maxJSONBody"}}</dt><dd>{{webbytes .Limits.MaxJSONBody}}</dd><dt>{{t .Locale "admin.maxPushOperations"}}</dt><dd>{{.Limits.MaxPushOperations}}</dd><dt>{{t .Locale "admin.maxPullPage"}}</dt><dd>{{.Limits.MaxPullPage}}</dd><dt>{{t .Locale "admin.maxBlobBytes"}}</dt><dd>{{webbytes .Limits.MaxBlobBytes}}</dd><dt>{{t .Locale "admin.maxVaultBlobBytes"}}</dt><dd>{{webbytes .Limits.MaxVaultBlobBytes}}</dd><dt>{{t .Locale "admin.maxUserBlobBytes"}}</dt><dd>{{webbytes .Limits.MaxUserBlobBytes}}</dd></dl></section>
</div>
</section>
{{end}}
@@ -0,0 +1,10 @@
{{define "admin_storage"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.storage"}}</h1>
{{if .Flash}}<p class="flash success" role="status">{{t .Locale .Flash}}</p>{{end}}
<div class="stat-grid"><article class="card stat"><strong>{{webbytes .Stats.DatabaseBytes}}</strong><span>{{t .Locale "admin.databaseBytes"}}</span></article><article class="card stat"><strong>{{webbytes .Stats.BlobBytes}}</strong><span>{{.Stats.Blobs}} {{t .Locale "admin.blobBytes"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article></div>
<section class="card panel"><dl class="details"><dt>{{t .Locale "admin.blobReferences"}}</dt><dd>{{.Stats.BlobReferences}}</dd><dt>{{t .Locale "admin.orphanBlobs"}}</dt><dd>{{.Stats.OrphanBlobs}}</dd><dt>{{t .Locale "admin.tempUploads"}}</dt><dd>{{.Stats.TempUploads}}</dd><dt>{{t .Locale "admin.expiredSessions"}}</dt><dd>{{.Stats.ExpiredSessions}}</dd><dt>{{t .Locale "admin.expiredTokens"}}</dt><dd>{{.Stats.ExpiredTokens}}</dd><dt>{{t .Locale "admin.lastCleanup"}}</dt><dd>{{webtime .Locale .Stats.LastCleanupAt}}</dd></dl></section>
<p class="muted">{{t .Locale "admin.retentionNote"}}</p><form method="post" action="/admin/action" class="inline-form"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="cleanup"><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 secondary" type="submit" data-confirm="{{t .Locale "admin.revokeConfirm"}}">{{t .Locale "admin.runCleanup"}}</button></form>
</div></section>
{{end}}
@@ -0,0 +1,9 @@
{{define "admin_users"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<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>
<form class="list-filter" method="get" action="{{.CurrentPath}}"><label>{{t .Locale "admin.search"}}<input name="q" value="{{.List.Query}}" maxlength="160"></label><label>{{t .Locale "device.status"}}<select name="status"><option value="">{{t .Locale "admin.all"}}</option><option value="active" {{if eq .List.Status "active"}}selected{{end}}>{{t .Locale "device.active"}}</option><option value="blocked" {{if eq .List.Status "blocked"}}selected{{end}}>{{t .Locale "admin.blocked"}}</option><option value="unconfirmed" {{if eq .List.Status "unconfirmed"}}selected{{end}}>{{t .Locale "admin.unconfirmed"}}</option></select></label><label>{{t .Locale "admin.sort"}}<select name="sort"><option value="created" {{if eq .List.Sort "created"}}selected{{end}}>{{t .Locale "admin.created"}}</option><option value="username" {{if eq .List.Sort "username"}}selected{{end}}>{{t .Locale "field.username"}}</option><option value="last_seen" {{if eq .List.Sort "last_seen"}}selected{{end}}>{{t .Locale "admin.lastSeen"}}</option></select></label><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form>
<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 "admin.vaults"}}</th><th>{{t .Locale "admin.lastSeen"}}</th><th>{{t .Locale "admin.created"}}</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>{{.Vaults}}</td><td>{{webtime $.Locale .LastSeen}}</td><td>{{webtime $.Locale .CreatedAt}}</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><details><summary>{{t $.Locale "admin.manage"}}</summary>{{if not .Confirmed}}<form class="stack compact" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="confirm-user"><input type="hidden" name="id" value="{{.ID}}"><label>{{t $.Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.confirmUser"}}</button></form>{{end}}<form class="stack compact" 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}}"><label>{{t $.Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button secondary" type="submit" data-confirm="{{t $.Locale "admin.revokeConfirm"}}">{{if .Blocked}}{{t $.Locale "admin.unblock"}}{{else}}{{t $.Locale "admin.block"}}{{end}}</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="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.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button secondary" type="submit" data-confirm="{{t $.Locale "admin.resetPasswordConfirm"}}">{{t $.Locale "admin.generatePassword"}}</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" autocomplete="current-password" required></label><button class="button danger" type="submit" data-confirm="{{t $.Locale "admin.deleteUserConfirm"}}">{{t $.Locale "admin.deleteUser"}}</button></form></details></td></tr>{{else}}<tr><td colspan="8" class="empty">{{t .Locale "admin.noUsers"}}</td></tr>{{end}}</tbody></table></div></section>
<nav class="pager" aria-label="{{t .Locale "admin.pagination"}}"><span>{{.List.Total}}</span>{{if .List.Previous}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Previous}}">{{t .Locale "admin.previous"}}</a>{{end}}<span>{{.List.Page}} / {{.List.Pages}}</span>{{if .List.Next}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>
</div></section>
{{end}}
@@ -0,0 +1,7 @@
{{define "admin_vaults"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<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"><a href="/admin/vault/?user={{.UserID}}&amp;vault={{.Vault}}">{{short .Vault 24}}</a></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>
</div></section>
{{end}}
+1 -1
View File
@@ -1,2 +1,2 @@
{{define "confirm"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "confirm.title"}}</h1><p class="muted">{{t .Locale "confirm.description"}}</p><form method="post" action="/api/v1/auth/confirm" class="stack"><input type="hidden" name="token" value="{{.Token}}"><button class="button primary" type="submit">{{t .Locale "confirm.action"}}</button></form></section>{{end}}
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "confirm.title"}}</h1><p class="muted">{{t .Locale "confirm.description"}}</p><form method="post" action="/api/v1/auth/confirm" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><input type="hidden" name="token" value="{{.Token}}"><button class="button primary" type="submit">{{t .Locale "confirm.action"}}</button></form></section>{{end}}
+6 -1
View File
@@ -1,2 +1,7 @@
{{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}}
{{define "content"}}
<section class="dashboard-head"><div><p class="eyebrow">{{t .Locale "user.account"}}</p><h1>{{.UserName}}</h1><p class="muted">{{.Email}} · {{if .UserConfirmed}}<span class="badge ok">{{t .Locale "user.emailConfirmed"}}</span>{{else}}<span class="badge danger">{{t .Locale "user.emailUnconfirmed"}}</span>{{end}}</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 {{if .FlashError}}error{{else}}success{{end}}" role="{{if .FlashError}}alert{{else}}status{{end}}">{{t .Locale .Flash}}</p>{{end}}
<section class="card panel"><p class="muted">{{t .Locale "user.connectInstruction"}}</p></section>
<section class="card table-card"><div class="section-heading"><h2>{{t .Locale "user.devices"}}</h2><form class="list-filter" method="get" action="/dashboard"><label class="sr-only">{{t .Locale "user.filterDevices"}}</label><input name="q" value="{{.List.Query}}" placeholder="{{t .Locale "user.filterDevices"}}"><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form></div>{{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.created"}}</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>{{webtime $.Locale .CreatedAt}}</td><td>{{webtime $.Locale .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}}
+1 -1
View File
@@ -1,5 +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>
<form method="post" action="/forgot" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><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}}
+1 -1
View File
@@ -5,6 +5,6 @@
<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>
<p class="muted">{{t .Locale "home.version"}} {{.Version}} · {{.BuildCommit}} · <span class="badge ok">{{statuslabel .Locale .Status}}</span></p>
</section>
{{end}}
+2 -1
View File
@@ -17,7 +17,7 @@
</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}}
<input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}">
<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>
@@ -29,6 +29,7 @@
</header>
<main class="page-shell">{{template "content" .}}</main>
<footer class="site-footer"><span>{{.ServerName}}</span><span>{{t .Locale "footer.localFirst"}}</span></footer>
<dialog id="confirm-dialog" class="confirm-dialog" aria-labelledby="confirm-dialog-title"><form method="dialog" class="stack"><h2 id="confirm-dialog-title">{{t .Locale "admin.confirmTitle"}}</h2><p id="confirm-dialog-message"></p><div class="actions"><button class="button secondary" value="cancel">{{t .Locale "admin.modalCancel"}}</button><button class="button danger" value="confirm">{{t .Locale "admin.modalConfirm"}}</button></div></form></dialog>
<script src="/static/app.js" defer></script>
</body>
</html>
+1 -1
View File
@@ -2,6 +2,6 @@
{{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>
<form method="post" action="/login" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><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}}
+1 -1
View File
@@ -2,6 +2,6 @@
{{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>
<form method="post" action="/register" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><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}}
+1 -1
View File
@@ -2,5 +2,5 @@
{{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>
<form method="post" action="/reset" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><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,2 @@
{{define "unavailable"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="message-card card"><p class="eyebrow">{{t .Locale "home.eyebrow"}}</p><h1>{{t .Locale .Heading}}</h1><p class="muted">{{t .Locale .Message}}</p></section>{{end}}
@@ -1,2 +1,10 @@
{{define "vault_detail"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="admin-shell"><div class="admin-content"><p class="eyebrow">{{t .Locale "admin.vaults"}}</p><h1 class="mono">{{short .VaultDetail.Vault 32}}</h1><p class="muted">{{.VaultDetail.User}}</p><div class="stat-grid"><article class="card stat"><strong>{{.VaultDetail.Devices}}</strong><span>{{t .Locale "admin.devices"}}</span></article><article class="card stat"><strong>{{.VaultDetail.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article><article class="card stat"><strong>{{.VaultDetail.BlobBytes}}</strong><span>{{t .Locale "admin.blobBytes"}}</span></article></div><section class="card panel"><dl class="details"><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{.VaultDetail.LastActivity}}</dd></dl><p class="muted">{{t .Locale "admin.vaultPrivacy"}}</p><a class="button secondary" href="/admin/vaults">{{t .Locale "common.back"}}</a></section></div></section>{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.vaults"}}</p><h1 class="mono">{{short .VaultDetail.Vault 32}}</h1><p class="muted">{{.VaultDetail.User}}</p>
<div class="stat-grid"><article class="card stat"><strong>{{.VaultDetail.Devices}}</strong><span>{{t .Locale "admin.devices"}} · {{.VaultDetail.Active}} {{t .Locale "admin.active"}} · {{.VaultDetail.Revoked}} {{t .Locale "admin.revoked"}}</span></article><article class="card stat"><strong>{{.VaultDetail.Operations}}</strong><span>{{t .Locale "admin.operations"}} · {{t .Locale "admin.sequence"}} {{.VaultDetail.Sequence}}</span></article><article class="card stat"><strong>{{webbytes .VaultDetail.BlobBytes}}</strong><span>{{t .Locale "admin.blobBytes"}}</span></article></div>
<section class="card panel"><dl class="details"><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{webtime .Locale .VaultDetail.LastActivity}}</dd></dl><p class="muted">{{t .Locale "admin.vaultPrivacy"}}</p></section>
<section class="card table-card"><h2>{{t .Locale "admin.devices"}}</h2><div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "device.version"}}</th><th>{{t .Locale "admin.ip"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th></tr></thead><tbody>{{range .VaultDevices}}<tr><td>{{.Name}}</td><td>{{.Version}}</td><td>{{.LastIP}}</td><td>{{webtime $.Locale .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></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noDevices"}}</td></tr>{{end}}</tbody></table></div></section>
<a class="button secondary" href="/admin/vaults">{{t .Locale "common.back"}}</a>
</div></section>
{{end}}
+292 -32
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -13,6 +14,38 @@ import (
"golang.org/x/crypto/bcrypt"
)
type oneTimeWebSecret struct {
Value string
ExpiresAt time.Time
}
// storeAdminOneTimeSecret keeps a generated password only long enough for the
// currently authenticated administrator to retrieve it once. It is never
// written to the database, URL, audit log, or cookie.
func (s *Server) storeAdminOneTimeSecret(sessionToken, secret string) {
s.secretMu.Lock()
defer s.secretMu.Unlock()
now := time.Now().UTC()
for key, value := range s.webSecrets {
if !now.Before(value.ExpiresAt) {
delete(s.webSecrets, key)
}
}
s.webSecrets[sha256Hex(sessionToken)] = oneTimeWebSecret{Value: secret, ExpiresAt: now.Add(5 * time.Minute)}
}
func (s *Server) takeAdminOneTimeSecret(sessionToken string) string {
s.secretMu.Lock()
defer s.secretMu.Unlock()
key := sha256Hex(sessionToken)
value, ok := s.webSecrets[key]
delete(s.webSecrets, key)
if !ok || !time.Now().UTC().Before(value.ExpiresAt) {
return ""
}
return value.Value
}
func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
@@ -21,6 +54,28 @@ func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
}
func (s *Server) handleAdminPasswordResult(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !s.requireAdminCookie(w, r) {
return
}
cookie, err := r.Cookie("admin_session")
if err != nil {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return
}
secret := s.takeAdminOneTimeSecret(cookie.Value)
if secret == "" {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return
}
w.Header().Set("Cache-Control", "no-store, max-age=0")
s.renderPage(w, r, "admin_password_result", webPage{Title: "admin.resetPassword", Admin: true, AdminPage: "users", OneTimeSecret: secret})
}
func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
@@ -35,7 +90,7 @@ func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request)
return
}
var d webVaultDetail
if err := s.db.QueryRow(`SELECT COALESCE((SELECT username FROM server_users WHERE id=?),''), COUNT(DISTINCT d.id), COUNT(DISTINCT o.op_id), COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE d.user_id=? AND d.vault_id=?`, userID, userID, vaultID).Scan(&d.User, &d.Devices, &d.Operations, &d.LastActivity); err != nil {
if err := s.db.QueryRow(`SELECT COALESCE((SELECT username FROM server_users WHERE id=?),''), COUNT(DISTINCT d.id), COUNT(DISTINCT CASE WHEN COALESCE(d.revoked_at,'')='' THEN d.id END), COUNT(DISTINCT CASE WHEN COALESCE(d.revoked_at,'')!='' THEN d.id END), COUNT(DISTINCT o.op_id), COALESCE(MAX(o.server_sequence),0), COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE d.user_id=? AND d.vault_id=?`, userID, userID, vaultID).Scan(&d.User, &d.Devices, &d.Active, &d.Revoked, &d.Operations, &d.Sequence, &d.LastActivity); err != nil {
jsonInternalError(w, err)
return
}
@@ -44,7 +99,31 @@ func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request)
jsonInternalError(w, err)
return
}
s.renderPage(w, r, "vault_detail", webPage{Title: "admin.vaults", Admin: true, VaultDetail: d})
rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(u.username,''),COALESCE(d.vault_id,''),COALESCE(d.client_version,''),COALESCE(d.last_ip,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at,COALESCE(d.token_prefix,''),COALESCE(d.token_suffix,'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id WHERE d.user_id=? AND d.vault_id=? ORDER BY d.created_at DESC`, userID, vaultID)
if err != nil {
jsonInternalError(w, err)
return
}
defer rows.Close()
var devices []webAdminDevice
for rows.Next() {
var device webAdminDevice
var revoked, prefix, suffix string
if err := rows.Scan(&device.ID, &device.Name, &device.User, &device.Vault, &device.Version, &device.LastIP, &device.LastSeen, &revoked, &device.CreatedAt, &prefix, &suffix); err != nil {
jsonInternalError(w, err)
return
}
device.Revoked = revoked != ""
if prefix != "" || suffix != "" {
device.TokenHint = prefix + "…" + suffix
}
devices = append(devices, device)
}
if err := rows.Err(); err != nil {
jsonInternalError(w, err)
return
}
s.renderPage(w, r, "vault_detail", webPage{Title: "admin.vaults", Admin: true, AdminPage: "vaults", VaultDetail: d, VaultDevices: devices})
}
func (s *Server) handleAdminCreateUserWeb(w http.ResponseWriter, r *http.Request) {
@@ -122,6 +201,20 @@ func (s *Server) handleAdminWeb(w http.ResponseWriter, r *http.Request) {
}
data := webPage{Title: "admin." + page, Admin: true, AdminPage: page, Stats: stats, Health: s.healthStatus(r.Context())}
switch page {
case "dashboard":
data.Audit, _, err = s.webAudit(webList{Page: 1, PerPage: 5})
if err == nil {
data.AdminDevices, _, err = s.webAdminDevices(webList{Page: 1, PerPage: 5})
}
if !data.Health.DatabaseReachable || !data.Health.BlobStorageWritable {
data.Warnings = append(data.Warnings, "admin.warningReadiness")
}
if s.smtpGet("smtp_host") == "" {
data.Warnings = append(data.Warnings, "admin.warningSMTP")
}
if stats.Operations > 100000 {
data.Warnings = append(data.Warnings, "admin.warningOperations")
}
case "users":
data.List = webListFromRequest(r)
data.AdminUsers, data.List, err = s.webAdminUsers(data.List)
@@ -135,21 +228,38 @@ func (s *Server) handleAdminWeb(w http.ResponseWriter, r *http.Request) {
data.Audit, data.List, err = s.webAudit(data.List)
case "settings":
data.SMTP = s.webSMTP()
switch r.URL.Query().Get("flash") {
case "settings_saved":
data.Flash = "admin.settingsSaved"
case "smtp_saved":
data.Flash = "admin.smtpSaved"
case "smtp_test_passed":
data.Flash = "admin.smtpPassed"
case "smtp_test_failed":
data.Flash = "admin.smtpTestFailed"
}
case "storage":
if r.URL.Query().Get("flash") == "cleanup_done" {
data.Flash = "admin.cleanupDone"
}
}
if err != nil {
log.Printf("admin %s: %v", page, err)
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/admin/dashboard")
return
}
if page == "settings" {
s.renderPage(w, r, "admin_settings", data)
return
}
s.renderPage(w, r, "admin", data)
s.renderPage(w, r, "admin_"+page, data)
}
func webListFromRequest(r *http.Request) webList {
list := webList{Query: strings.TrimSpace(r.URL.Query().Get("q")), Status: strings.TrimSpace(r.URL.Query().Get("status")), Page: 1, PerPage: 25}
trim := func(value string) string {
value = strings.TrimSpace(value)
if len(value) > 160 {
return value[:160]
}
return value
}
list := webList{Query: trim(r.URL.Query().Get("q")), Status: trim(r.URL.Query().Get("status")), Sort: trim(r.URL.Query().Get("sort")), User: trim(r.URL.Query().Get("user")), Vault: trim(r.URL.Query().Get("vault")), Version: trim(r.URL.Query().Get("version")), Event: trim(r.URL.Query().Get("event")), Severity: trim(r.URL.Query().Get("severity")), Page: 1, PerPage: 25}
if value, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && value > 0 {
list.Page = value
}
@@ -198,9 +308,14 @@ func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) {
return nil, list, err
}
list = finishWebList(list, total)
order := map[string]string{"username": "u.username COLLATE NOCASE ASC", "last_seen": "COALESCE(u.last_seen,'') DESC", "created": "u.created_at DESC"}[list.Sort]
if order == "" {
list.Sort = "created"
order = "u.created_at DESC"
}
queryArgs := append([]interface{}{}, args...)
queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage)
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`+where+` GROUP BY u.id ORDER BY u.created_at DESC LIMIT ? OFFSET ?`, queryArgs...)
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),(SELECT COUNT(DISTINCT vd.vault_id) FROM server_devices vd WHERE vd.user_id=u.id AND COALESCE(vd.vault_id,'')!='') FROM server_users u LEFT JOIN server_user_devices ud ON ud.user_id=u.id`+where+` GROUP BY u.id ORDER BY `+order+` LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return nil, list, err
}
@@ -209,7 +324,7 @@ func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) {
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 {
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &confirmed, &blocked, &u.CreatedAt, &u.LastSeen, &u.Devices, &u.Vaults); err != nil {
return nil, list, err
}
u.Confirmed = confirmed != 0
@@ -222,27 +337,44 @@ func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) {
func (s *Server) webAdminDevices(list webList) ([]webAdminDevice, webList, error) {
where := ""
args := []interface{}{}
if list.Query != "" {
where = " WHERE (d.name LIKE ? OR u.username LIKE ? OR d.vault_id LIKE ?)"
like := "%" + list.Query + "%"
args = append(args, like, like, like)
}
if list.Status == "active" || list.Status == "revoked" {
condition := map[string]string{"active": "COALESCE(d.revoked_at,'')=''", "revoked": "COALESCE(d.revoked_at,'')!=''"}[list.Status]
addCondition := func(condition string, values ...interface{}) {
if where == "" {
where = " WHERE " + condition
} else {
where += " AND " + condition
}
args = append(args, values...)
}
if list.Query != "" {
like := "%" + list.Query + "%"
addCondition("(d.name LIKE ? OR u.username LIKE ? OR d.vault_id LIKE ?)", like, like, like)
}
if list.Status == "active" || list.Status == "revoked" {
condition := map[string]string{"active": "COALESCE(d.revoked_at,'')=''", "revoked": "COALESCE(d.revoked_at,'')!=''"}[list.Status]
addCondition(condition)
}
if list.User != "" {
addCondition("u.username LIKE ?", "%"+list.User+"%")
}
if list.Vault != "" {
addCondition("d.vault_id LIKE ?", "%"+list.Vault+"%")
}
if list.Version != "" {
addCondition("d.client_version LIKE ?", "%"+list.Version+"%")
}
var total int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id`+where, args...).Scan(&total); err != nil {
return nil, list, err
}
list = finishWebList(list, total)
order := map[string]string{"name": "d.name COLLATE NOCASE ASC", "last_seen": "COALESCE(d.last_seen,'') DESC", "created": "d.created_at DESC"}[list.Sort]
if order == "" {
list.Sort = "created"
order = "d.created_at DESC"
}
queryArgs := append([]interface{}{}, args...)
queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage)
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`+where+` ORDER BY d.created_at DESC LIMIT ? OFFSET ?`, queryArgs...)
rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(u.username,''),COALESCE(d.vault_id,''),COALESCE(d.client_version,''),COALESCE(d.last_ip,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at,COALESCE(d.token_prefix,''),COALESCE(d.token_suffix,'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id`+where+` ORDER BY `+order+` LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return nil, list, err
}
@@ -250,10 +382,13 @@ func (s *Server) webAdminDevices(list webList) ([]webAdminDevice, webList, error
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 {
var revoked, prefix, suffix string
if err := rows.Scan(&d.ID, &d.Name, &d.User, &d.Vault, &d.Version, &d.LastIP, &d.LastSeen, &revoked, &d.CreatedAt, &prefix, &suffix); err != nil {
return nil, list, err
}
if prefix != "" || suffix != "" {
d.TokenHint = prefix + "…" + suffix
}
d.Revoked = revoked != ""
if d.LastSeen == "" {
d.LastSeen = "—"
@@ -283,19 +418,37 @@ func (s *Server) webVaults() ([]webVault, error) {
func (s *Server) webAudit(list webList) ([]webAudit, webList, error) {
where := ""
args := []interface{}{}
addCondition := func(condition string, values ...interface{}) {
if where == "" {
where = " WHERE " + condition
} else {
where += " AND " + condition
}
args = append(args, values...)
}
if list.Query != "" {
where = " WHERE (event_type LIKE ? OR user_id LIKE ? OR device_id LIKE ?)"
like := "%" + list.Query + "%"
args = append(args, like, like, like)
addCondition("(a.event_type LIKE ? OR a.user_id LIKE ? OR a.device_id LIKE ?)", like, like, like)
}
if list.Event != "" {
addCondition("a.event_type LIKE ?", "%"+list.Event+"%")
}
if list.User != "" {
addCondition("a.user_id LIKE ?", "%"+list.User+"%")
}
if list.Severity == "error" {
addCondition("(a.event_type LIKE '%failed%' OR a.event_type LIKE '%error%')")
} else if list.Severity == "warning" {
addCondition("a.event_type LIKE '%rate_limit%'")
}
var total int
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_audit_log"+where, args...).Scan(&total); err != nil {
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_audit_log a"+where, args...).Scan(&total); err != nil {
return nil, list, err
}
list = finishWebList(list, total)
queryArgs := append([]interface{}{}, args...)
queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage)
rows, err := s.db.Query(`SELECT event_type,COALESCE(user_id,''),COALESCE(device_id,''),created_at FROM server_audit_log`+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, queryArgs...)
rows, err := s.db.Query(`SELECT a.event_type,COALESCE(u.username,a.user_id,''),COALESCE(d.name,a.device_id,''),COALESCE(a.ip,''),COALESCE(a.message,''),a.created_at FROM server_audit_log a LEFT JOIN server_users u ON u.id=a.user_id LEFT JOIN server_devices d ON d.id=a.device_id`+where+` ORDER BY a.id DESC LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return nil, list, err
}
@@ -303,14 +456,25 @@ func (s *Server) webAudit(list webList) ([]webAudit, webList, error) {
var out []webAudit
for rows.Next() {
var a webAudit
if err := rows.Scan(&a.Event, &a.User, &a.Device, &a.At); err != nil {
if err := rows.Scan(&a.Event, &a.User, &a.Device, &a.IP, &a.Message, &a.At); err != nil {
return nil, list, err
}
a.Severity = auditSeverity(a.Event)
out = append(out, a)
}
return out, list, rows.Err()
}
func auditSeverity(event string) string {
if strings.Contains(event, "failed") || strings.Contains(event, "error") {
return "error"
}
if strings.Contains(event, "rate_limit") || strings.Contains(event, "revoked") || strings.Contains(event, "blocked") {
return "warning"
}
return "info"
}
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")}
}
@@ -343,10 +507,19 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
if locale != "ru" && locale != "en" {
locale = "en"
}
publicURL := strings.TrimRight(strings.TrimSpace(r.FormValue("public_url")), "/")
if publicURL != "" {
parsed, err := url.ParseRequestURI(publicURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
s.renderWebError(w, r, http.StatusBadRequest, "error.invalidPublicURL", "/admin/settings")
return
}
}
s.cfg.mu.Lock()
s.cfg.Web.DefaultLocale = locale
s.cfg.Web.AllowRegistration = r.FormValue("allow_registration") == "on"
s.cfg.Web.ServerName = strings.TrimSpace(r.FormValue("server_name"))
s.cfg.PublicURL = publicURL
err := s.cfg.saveLocked()
s.cfg.mu.Unlock()
if err != nil {
@@ -354,7 +527,7 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
return
}
s.auditLog("web_settings_updated", "", "", s.clientIP(r), "updated by administrator")
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
http.Redirect(w, r, "/admin/settings?flash=settings_saved", http.StatusSeeOther)
case "toggle-user":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
@@ -412,14 +585,36 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
}
s.auditLog("user_updated", id, "", s.clientIP(r), "updated by administrator")
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
case "confirm-user":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
return
}
id := r.FormValue("id")
result, err := s.db.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", id)
if err != nil {
jsonInternalError(w, err)
return
}
if changed, err := result.RowsAffected(); err != nil || changed == 0 {
if err != nil {
jsonInternalError(w, err)
} else {
s.renderWebError(w, r, http.StatusNotFound, "error.badRequest", "/admin/users")
}
return
}
s.auditLog("user_confirmed", id, "", s.clientIP(r), "confirmed 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")
id := r.FormValue("id")
password, err := randomSecret(16)
if err != nil {
jsonInternalError(w, err)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
@@ -446,7 +641,13 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
return
}
s.auditLog("user_password_reset", id, "", s.clientIP(r), "reset by administrator")
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
cookie, err := r.Cookie("admin_session")
if err != nil {
jsonInternalError(w, err)
return
}
s.storeAdminOneTimeSecret(cookie.Value, password)
http.Redirect(w, r, "/admin/password-result", http.StatusSeeOther)
case "delete-user":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
@@ -483,6 +684,43 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
}
s.auditLog("device_revoked", "", id, s.clientIP(r), "revoked by administrator")
http.Redirect(w, r, "/admin/devices", http.StatusSeeOther)
case "delete-device":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/devices")
return
}
id := r.FormValue("id")
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
var revoked string
if err := tx.QueryRow("SELECT COALESCE(revoked_at,'') FROM server_devices WHERE id=?", id).Scan(&revoked); err != nil {
if err == sql.ErrNoRows {
s.renderWebError(w, r, http.StatusNotFound, "error.badRequest", "/admin/devices")
} else {
jsonInternalError(w, err)
}
return
}
if revoked == "" {
s.renderWebError(w, r, http.StatusConflict, "error.deviceMustBeRevoked", "/admin/devices")
return
}
for _, statement := range []string{"DELETE FROM server_user_devices WHERE device_id=?", "DELETE FROM server_devices 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("device_deleted", "", id, s.clientIP(r), "revoked device deleted 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")
@@ -511,7 +749,29 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
return
}
s.auditLog("smtp_settings_updated", "", "", s.clientIP(r), "updated by administrator")
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
http.Redirect(w, r, "/admin/settings?flash=smtp_saved", http.StatusSeeOther)
case "smtp-test":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/settings")
return
}
smtp := s.webSMTP()
to := strings.TrimSpace(r.FormValue("test_to"))
if to == "" {
to = smtp.From
}
if smtp.Host == "" || smtp.Port == "" || smtp.From == "" || to == "" {
http.Redirect(w, r, "/admin/settings?flash=smtp_test_failed", http.StatusSeeOther)
return
}
if err := s.smtpTest(smtp.Host, smtp.Port, smtp.User, s.smtpGet("smtp_pass"), smtp.Security, smtp.From, to); err != nil {
log.Printf("admin SMTP test failed: %v", err)
s.auditLog("smtp_test_failed", "", "", s.clientIP(r), "tested by administrator")
http.Redirect(w, r, "/admin/settings?flash=smtp_test_failed", http.StatusSeeOther)
return
}
s.auditLog("smtp_test_passed", "", "", s.clientIP(r), "tested by administrator")
http.Redirect(w, r, "/admin/settings?flash=smtp_test_passed", http.StatusSeeOther)
case "cleanup":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/storage")
@@ -522,7 +782,7 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
return
}
s.auditLog("retention_cleanup", "", "", s.clientIP(r), "safe retention cleanup run by administrator")
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
http.Redirect(w, r, "/admin/storage?flash=cleanup_done", http.StatusSeeOther)
default:
s.renderWebError(w, r, http.StatusBadRequest, "error.badRequest", "/admin/dashboard")
}
+40 -1
View File
@@ -1,11 +1,15 @@
package server
import (
"crypto/subtle"
"net/http"
"strings"
)
const webLocaleCookieName = "verstak_locale"
const (
webLocaleCookieName = "verstak_locale"
webLocaleCSRFCookieName = "verstak_locale_csrf"
)
func isSupportedWebLocale(locale string) bool {
return locale == "en" || locale == "ru"
@@ -69,3 +73,38 @@ func (s *Server) setWebLocale(w http.ResponseWriter, r *http.Request, locale str
Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: 365 * 24 * 60 * 60,
})
}
// webLocaleCSRF protects the public language-preference form without
// conflating it with session CSRF tokens. Its value is rendered by the server,
// while the matching cookie is HttpOnly and never read by client-side code.
func (s *Server) webLocaleCSRF(w http.ResponseWriter, r *http.Request) string {
if cookie, err := r.Cookie(webLocaleCSRFCookieName); err == nil && cookie.Value != "" {
return cookie.Value
}
token, err := randomSecret(32)
if err != nil {
return ""
}
http.SetCookie(w, &http.Cookie{
Name: webLocaleCSRFCookieName, Value: token, Path: "/", HttpOnly: true,
Secure: s.requestIsHTTPS(r), SameSite: http.SameSiteStrictMode, MaxAge: 24 * 60 * 60,
})
return token
}
func (s *Server) verifyWebLocaleCSRF(r *http.Request) bool {
cookie, err := r.Cookie(webLocaleCSRFCookieName)
if err != nil || cookie.Value == "" || !s.sameOrigin(r) {
return false
}
candidate := r.FormValue("locale_csrf")
return candidate != "" && subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(candidate)) == 1
}
func (s *Server) requirePublicWebMutation(w http.ResponseWriter, r *http.Request, back string) bool {
if s.verifyWebLocaleCSRF(r) {
return true
}
s.renderWebError(w, r, http.StatusForbidden, "error.tryAgain", back)
return false
}
+315 -2
View File
@@ -21,6 +21,16 @@ func TestResolveWebLocaleCookieOverridesSystemAcceptLanguage(t *testing.T) {
}
}
func TestFormatWebTimeUsesSelectedLocale(t *testing.T) {
stamp := "2026-07-17T13:45:00Z"
if got := formatWebTime("ru", stamp); !strings.Contains(got, "17.07.2026") {
t.Fatalf("Russian timestamp = %q", got)
}
if got := formatWebTime("en", stamp); !strings.Contains(got, "Jul 17, 2026") {
t.Fatalf("English timestamp = %q", got)
}
}
func TestTranslationCatalogsHaveMatchingKeysAndHideUnknownKeys(test *testing.T) {
for key := range _translations["en"] {
if _, ok := _translations["ru"][key]; !ok {
@@ -56,6 +66,20 @@ func TestEmbeddedTemplatesUseExternalAssetsAndNoInlineEventHandlers(t *testing.T
}
}
func TestConfirmationUsesLocalDialogInsteadOfBrowserPrompt(t *testing.T) {
layout, err := webFS.ReadFile("web/templates/layout.html")
if err != nil {
t.Fatal(err)
}
script, err := webFS.ReadFile("web/static/app.js")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(layout), `id="confirm-dialog"`) || !strings.Contains(string(script), ".showModal()") || strings.Contains(string(script), "window.confirm") {
t.Fatalf("local confirmation dialog is missing or browser prompt remains")
}
}
func TestEmbeddedTemplateTranslationKeysExist(t *testing.T) {
entries, err := fs.Glob(webFS, "web/templates/*.html")
if err != nil {
@@ -102,6 +126,22 @@ func TestPublicHomeUsesSharedLocalizedTemplateLayout(t *testing.T) {
}
}
func TestPublicHomeUsesUnavailablePageWhenReadinessFails(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
s.SetupRoutes()
if err := s.db.Close(); err != nil {
t.Fatal(err)
}
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/", nil))
if res.Code != http.StatusServiceUnavailable || !strings.Contains(res.Body.String(), `<html lang="en">`) {
t.Fatalf("unavailable page=%d: %s", res.Code, res.Body.String())
}
}
func TestPublicTemplateRoutesRenderInBothLocales(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
@@ -125,6 +165,39 @@ func TestPublicTemplateRoutesRenderInBothLocales(t *testing.T) {
}
}
func TestAdminPagesRenderWithActiveNavigationInBothLocales(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','alice@example.test','hash',1,'2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,created_at) VALUES ('d1','Laptop','legacy','u1','vault-a','2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
token, _, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
for _, locale := range []string{"ru", "en"} {
for _, tc := range []struct{ path, active string }{
{"/admin/dashboard", "/admin/dashboard"}, {"/admin/users", "/admin/users"}, {"/admin/devices", "/admin/devices"}, {"/admin/vaults", "/admin/vaults"}, {"/admin/vault/?user=u1&vault=vault-a", "/admin/vaults"}, {"/admin/storage", "/admin/storage"}, {"/admin/audit", "/admin/audit"}, {"/admin/settings", "/admin/settings"}, {"/admin/diagnostics", "/admin/diagnostics"},
} {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
req.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
req.AddCookie(&http.Cookie{Name: webLocaleCookieName, Value: locale})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `<html lang="`+locale+`">`) || !strings.Contains(res.Body.String(), `href="`+tc.active+`" aria-current="page"`) {
t.Fatalf("%s %s = %d; active navigation missing: %s", locale, tc.path, res.Code, res.Body.String())
}
}
}
}
func TestConfirmationPageUsesSharedTemplateAndEscapesToken(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
@@ -223,8 +296,9 @@ func TestLocaleSelectionUsesCookieAndPRG(t *testing.T) {
}
defer s.Close()
s.SetupRoutes()
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/login"))
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/login&locale_csrf=locale-test-token"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "locale-test-token"})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/login" {
@@ -243,8 +317,9 @@ func TestLocaleSelectionPreservesResetQuery(t *testing.T) {
}
defer s.Close()
s.SetupRoutes()
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/reset%3Ftoken%3Dopaque-token"))
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/reset%3Ftoken%3Dopaque-token&locale_csrf=locale-test-token"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "locale-test-token"})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/reset?token=opaque-token" {
@@ -252,6 +327,52 @@ func TestLocaleSelectionPreservesResetQuery(t *testing.T) {
}
}
func TestLocaleSelectionRejectsMissingOrMismatchedCSRF(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
for _, tc := range []struct {
name, body, cookie string
}{
{"missing", "locale=ru&from=/login", ""},
{"mismatched", "locale=ru&from=/login&locale_csrf=other", "locale-test-token"},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if tc.cookie != "" {
req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: tc.cookie})
}
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusForbidden {
t.Fatalf("locale CSRF status=%d, want 403", res.Code)
}
})
}
}
func TestPublicWebFormsRejectMissingCSRF(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
for _, path := range []string{"/register", "/login", "/forgot", "/reset", "/admin/login"} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("username=alice&email=alice%40example.test&password=password&confirm=password"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusForbidden {
t.Fatalf("%s without public CSRF = %d, want 403", path, res.Code)
}
}
}
func TestWebResponsesHaveSecurityHeaders(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
@@ -349,6 +470,198 @@ func TestAdminUserListSearchStatusAndPagination(t *testing.T) {
}
}
func TestAdminSettingsRendersAndSavesSMTPConfiguration(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.cfg.SetAdmin("admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
s.SetupRoutes()
token, csrf, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
get := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
get.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
get.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
getResult := httptest.NewRecorder()
s.Handler().ServeHTTP(getResult, get)
if getResult.Code != http.StatusOK {
t.Fatalf("settings page = %d: %s", getResult.Code, getResult.Body.String())
}
for _, field := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_security", "smtp_from", "server_url"} {
if !strings.Contains(getResult.Body.String(), `name="`+field+`"`) {
t.Fatalf("settings page is missing SMTP field %q: %s", field, getResult.Body.String())
}
}
body := "csrf_token=" + csrf + "&action=smtp&smtp_host=mail.example.test&smtp_port=587&smtp_user=mailer&smtp_pass=mail-secret&smtp_security=starttls&smtp_from=sync%40example.test&server_url=https%3A%2F%2Fsync.example.test&password=correct+horse+battery+staple"
post := httptest.NewRequest(http.MethodPost, "/admin/action", strings.NewReader(body))
post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
post.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
post.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
postResult := httptest.NewRecorder()
s.Handler().ServeHTTP(postResult, post)
if postResult.Code != http.StatusSeeOther || postResult.Header().Get("Location") != "/admin/settings?flash=smtp_saved" {
t.Fatalf("save SMTP = %d %q: %s", postResult.Code, postResult.Header().Get("Location"), postResult.Body.String())
}
if got := s.smtpGet("smtp_host"); got != "mail.example.test" {
t.Fatalf("saved SMTP host = %q", got)
}
}
func TestUserDashboardOnlyRendersOwnFilteredDevices(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
for _, user := range []struct{ id, name string }{{"user-a", "alice"}, {"user-b", "bob"}} {
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES (?, ?, ?, 'hash', 1, '2026-01-01T00:00:00Z')", user.id, user.name, user.name+"@example.test"); err != nil {
t.Fatal(err)
}
}
for _, device := range []struct{ id, user, name string }{{"device-a", "user-a", "Alice laptop"}, {"device-b", "user-b", "Bob workstation"}} {
if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,created_at) VALUES (?, ?, ?, ?, 'vault', '2026-01-01T00:00:00Z')", device.id, device.name, "key-"+device.id, device.user); err != nil {
t.Fatal(err)
}
if _, err := s.db.Exec("INSERT INTO server_user_devices (user_id,device_id) VALUES (?,?)", device.user, device.id); err != nil {
t.Fatal(err)
}
}
token, _, err := s.createSession(sessionScopeUser, "user-a")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/dashboard?q=Alice", nil)
req.AddCookie(&http.Cookie{Name: "user_session", Value: token})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("dashboard=%d: %s", res.Code, res.Body.String())
}
if !strings.Contains(res.Body.String(), "Alice laptop") || strings.Contains(res.Body.String(), "Bob workstation") {
t.Fatalf("dashboard leaked or missed device: %s", res.Body.String())
}
}
func TestAdminDeviceFiltersAndAuditSearchRemainBounded(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
for _, user := range []struct{ id, name string }{{"u1", "alice"}, {"u2", "bob"}} {
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES (?, ?, ?, 'hash', 1, '2026-01-01T00:00:00Z')", user.id, user.name, user.name+"@example.test"); err != nil {
t.Fatal(err)
}
}
for _, device := range []struct{ id, user, vault, version string }{{"d1", "u1", "vault-a", "2.0"}, {"d2", "u2", "vault-b", "1.0"}} {
if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,client_version,created_at) VALUES (?, ?, ?, ?, ?, ?, '2026-01-01T00:00:00Z')", device.id, device.id, "key-"+device.id, device.user, device.vault, device.version); err != nil {
t.Fatal(err)
}
}
devices, list, err := s.webAdminDevices(webList{User: "alice", Vault: "vault-a", Version: "2.0", Sort: "name", Page: 1, PerPage: 25})
if err != nil || len(devices) != 1 || devices[0].ID != "d1" || list.Sort != "name" {
t.Fatalf("filtered devices=%+v list=%+v err=%v", devices, list, err)
}
if _, err := s.db.Exec("INSERT INTO server_audit_log (event_type,user_id,message,created_at) VALUES ('device_paired','u1','safe','2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
audit, _, err := s.webAudit(webList{Event: "' OR 1=1 --", Page: 1, PerPage: 25})
if err != nil || len(audit) != 0 {
t.Fatalf("audit injection filter returned=%+v err=%v", audit, err)
}
}
func TestAdminCanConfirmUnconfirmedUserWithCSRFAndReauth(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.cfg.SetAdmin("admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
s.SetupRoutes()
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','alice@example.test','hash',0,'2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
token, csrf, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
body := "csrf_token=" + csrf + "&action=confirm-user&id=u1&password=correct+horse+battery+staple"
req := httptest.NewRequest(http.MethodPost, "/admin/action", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/admin/users" {
t.Fatalf("confirm user=%d %q: %s", res.Code, res.Header().Get("Location"), res.Body.String())
}
var confirmed int
if err := s.db.QueryRow("SELECT confirmed FROM server_users WHERE id='u1'").Scan(&confirmed); err != nil || confirmed != 1 {
t.Fatalf("confirmed=%d err=%v", confirmed, err)
}
}
func TestAdminPasswordResetShowsGeneratedSecretOnce(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.cfg.SetAdmin("admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
s.SetupRoutes()
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','alice@example.test','old',1,'2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
adminToken, csrf, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
if _, _, err := s.createSession(sessionScopeUser, "u1"); err != nil {
t.Fatal(err)
}
body := "csrf_token=" + csrf + "&action=reset-user-password&id=u1&password=correct+horse+battery+staple"
request := httptest.NewRequest(http.MethodPost, "/admin/action", strings.NewReader(body))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
request.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/admin/password-result" {
t.Fatalf("reset=%d %q: %s", response.Code, response.Header().Get("Location"), response.Body.String())
}
var sessions int
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_sessions WHERE scope='user' AND subject_id='u1'").Scan(&sessions); err != nil || sessions != 0 {
t.Fatalf("user sessions=%d err=%v", sessions, err)
}
resultRequest := httptest.NewRequest(http.MethodGet, "/admin/password-result", nil)
resultRequest.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
resultResponse := httptest.NewRecorder()
s.Handler().ServeHTTP(resultResponse, resultRequest)
if resultResponse.Code != http.StatusOK || !strings.Contains(resultResponse.Header().Get("Cache-Control"), "no-store") || !strings.Contains(resultResponse.Body.String(), "one-time-secret") {
t.Fatalf("password result=%d headers=%v body=%s", resultResponse.Code, resultResponse.Header(), resultResponse.Body.String())
}
secondRequest := httptest.NewRequest(http.MethodGet, "/admin/password-result", nil)
secondRequest.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
secondResponse := httptest.NewRecorder()
s.Handler().ServeHTTP(secondResponse, secondRequest)
if secondResponse.Code != http.StatusSeeOther || secondResponse.Header().Get("Location") != "/admin/users" {
t.Fatalf("second password result=%d %q", secondResponse.Code, secondResponse.Header().Get("Location"))
}
}
func TestResolveWebLocaleSystemUsesAcceptLanguageAndFallsBack(t *testing.T) {
cfg := DefaultConfig()
cfg.Web.DefaultLocale = "en"
+130 -16
View File
@@ -5,6 +5,8 @@ import (
"html/template"
"io/fs"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@@ -23,10 +25,15 @@ type webPage struct {
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
@@ -37,9 +44,11 @@ type webPage struct {
FormAction string
BackURL string
Token string
OneTimeSecret string
Admin bool
UserName string
Email string
UserConfirmed bool
Devices []webDevice
AdminPage string
Stats ServerStats
@@ -47,7 +56,9 @@ type webPage struct {
AdminUsers []webAdminUser
AdminDevices []webAdminDevice
Vaults []webVault
VaultDevices []webAdminDevice
Audit []webAudit
Warnings []string
SMTP webSMTP
List webList
VaultDetail webVaultDetail
@@ -56,33 +67,50 @@ type webPage struct {
type webAdminUser struct {
ID, Username, Email, CreatedAt, LastSeen string
Confirmed, Blocked bool
Devices int
Devices, Vaults int
}
type webAdminDevice struct {
ID, Name, User, Vault, Version, LastSeen, CreatedAt string
Revoked bool
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, At 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 string
Status string
Page int
PerPage int
Total int
Pages int
Previous int
Next int
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, Operations int
Devices, Active, Revoked int
Operations, Sequence int
BlobBytes int64
}
@@ -99,7 +127,13 @@ type webDevice struct {
func newWebRenderer() (*webRenderer, error) {
funcs := template.FuncMap{
"t": func(locale, key string) string { return t(locale, key) },
"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
@@ -107,12 +141,12 @@ func newWebRenderer() (*webRenderer, error) {
return value[:length-1] + "…"
},
}
layout, err := template.New("layout.html").Funcs(funcs).ParseFS(webFS, "web/templates/layout.html")
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", "login", "register", "forgot", "reset", "confirm", "message", "error", "admin_login", "dashboard", "admin", "admin_create_user", "vault_detail", "admin_settings"} {
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
@@ -129,6 +163,74 @@ func newWebRenderer() (*webRenderer, error) {
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)
}
@@ -142,8 +244,12 @@ func (s *Server) renderPageStatus(w http.ResponseWriter, r *http.Request, page s
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
@@ -188,6 +294,10 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
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})
}
@@ -200,6 +310,10 @@ func (s *Server) handleLocale(w http.ResponseWriter, r *http.Request) {
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"