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

840 lines
30 KiB
Go

package server
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"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)
return
}
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)
return
}
if !s.requireAdminCookie(w, r) {
return
}
userID, vaultID := r.URL.Query().Get("user"), r.URL.Query().Get("vault")
if userID == "" || vaultID == "" {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/vaults")
return
}
var d webVaultDetail
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
}
d.Vault = vaultID
if err := s.db.QueryRow(`SELECT COALESCE(SUM(size),0) FROM server_blob_refs WHERE user_id=? AND vault_id=?`, userID, vaultID).Scan(&d.BlobBytes); err != nil {
jsonInternalError(w, err)
return
}
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) {
if !s.requireAdminCookie(w, r) {
return
}
switch r.Method {
case http.MethodGet:
s.renderPage(w, r, "admin_create_user", webPage{Title: "admin.createUser", Admin: true})
case http.MethodPost:
if !s.requireAdminMutation(w, r) {
return
}
if err := r.ParseForm(); err != nil {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/create-user")
return
}
username, email, password := strings.TrimSpace(r.FormValue("username")), strings.TrimSpace(r.FormValue("email")), r.FormValue("password")
if username == "" || email == "" || password == "" {
s.renderPage(w, r, "admin_create_user", webPage{Title: "admin.createUser", Admin: true, Flash: "error.allFieldsRequired"})
return
}
if err := validatePassword(password); err != "" {
s.renderPage(w, r, "admin_create_user", webPage{Title: "admin.createUser", Admin: true, Flash: "error.passwordInvalid"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
jsonInternalError(w, err)
return
}
id := make([]byte, 12)
if _, err := rand.Read(id); err != nil {
jsonInternalError(w, err)
return
}
userID := hex.EncodeToString(id)
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES (?, ?, ?, ?, 1, ?)", userID, username, strings.ToLower(email), string(hash), time.Now().UTC().Format(time.RFC3339)); err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
s.renderPage(w, r, "admin_create_user", webPage{Title: "admin.createUser", Admin: true, Flash: "error.accountTaken"})
return
}
jsonInternalError(w, err)
return
}
s.auditLog("user_created", userID, "", s.clientIP(r), "created by administrator")
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
default:
methodNotAllowed(w, http.MethodGet, http.MethodPost)
}
}
func (s *Server) handleAdminWeb(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !s.requireAdminCookie(w, r) {
return
}
page := strings.TrimPrefix(r.URL.Path, "/admin/")
if page == "" || page == "admin" {
page = "dashboard"
}
allowed := map[string]bool{"dashboard": true, "users": true, "devices": true, "vaults": true, "storage": true, "audit": true, "settings": true, "diagnostics": true}
if !allowed[page] {
s.handleNotFound(w, r)
return
}
stats, err := s.Stats(r.Context())
if err != nil {
log.Printf("admin stats: %v", err)
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return
}
data := webPage{Title: "admin." + page, Admin: true, AdminPage: page, Stats: stats, Health: s.healthStatus(r.Context())}
switch page {
case "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)
case "devices":
data.List = webListFromRequest(r)
data.AdminDevices, data.List, err = s.webAdminDevices(data.List)
case "vaults":
data.Vaults, err = s.webVaults()
case "audit":
data.List = webListFromRequest(r)
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
}
s.renderPage(w, r, "admin_"+page, data)
}
func webListFromRequest(r *http.Request) webList {
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
}
if value, err := strconv.Atoi(r.URL.Query().Get("per_page")); err == nil && value > 0 && value <= 100 {
list.PerPage = value
}
return list
}
func finishWebList(list webList, total int) webList {
list.Total = total
list.Pages = (total + list.PerPage - 1) / list.PerPage
if list.Pages == 0 {
list.Pages = 1
}
if list.Page > list.Pages {
list.Page = list.Pages
}
if list.Page > 1 {
list.Previous = list.Page - 1
}
if list.Page < list.Pages {
list.Next = list.Page + 1
}
return list
}
func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) {
where := ""
args := []interface{}{}
if list.Query != "" {
where = " WHERE (u.username LIKE ? OR u.email LIKE ?)"
like := "%" + list.Query + "%"
args = append(args, like, like)
}
if list.Status == "active" || list.Status == "blocked" || list.Status == "unconfirmed" {
condition := map[string]string{"active": "u.confirmed=1 AND u.blocked=0", "blocked": "u.blocked=1", "unconfirmed": "u.confirmed=0"}[list.Status]
if where == "" {
where = " WHERE " + condition
} else {
where += " AND " + condition
}
}
var total int
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_users u"+where, args...).Scan(&total); err != nil {
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),(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
}
defer rows.Close()
var out []webAdminUser
for rows.Next() {
var u webAdminUser
var confirmed, blocked int
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &confirmed, &blocked, &u.CreatedAt, &u.LastSeen, &u.Devices, &u.Vaults); err != nil {
return nil, list, err
}
u.Confirmed = confirmed != 0
u.Blocked = blocked != 0
out = append(out, u)
}
return out, list, rows.Err()
}
func (s *Server) webAdminDevices(list webList) ([]webAdminDevice, 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 != "" {
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_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
}
defer rows.Close()
var out []webAdminDevice
for rows.Next() {
var d webAdminDevice
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 = ""
}
out = append(out, d)
}
return out, list, rows.Err()
}
func (s *Server) webVaults() ([]webVault, error) {
rows, err := s.db.Query(`SELECT COALESCE(u.username,''),d.user_id,d.vault_id,COUNT(DISTINCT d.id),COUNT(o.op_id),COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE COALESCE(d.user_id,'')!='' AND COALESCE(d.vault_id,'')!='' GROUP BY d.user_id,d.vault_id ORDER BY MAX(d.last_seen) DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []webVault
for rows.Next() {
var v webVault
if err := rows.Scan(&v.User, &v.UserID, &v.Vault, &v.Devices, &v.Operations, &v.LastActivity); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func (s *Server) webAudit(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 != "" {
like := "%" + list.Query + "%"
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 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 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
}
defer rows.Close()
var out []webAudit
for rows.Next() {
var a webAudit
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")}
}
func (s *Server) adminReauth(r *http.Request, subject string) bool {
return s.cfg.CheckAdmin(subject, r.FormValue("password"))
}
func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
session, ok := s.requireSession(w, r, sessionScopeAdmin)
if !ok || !s.verifyCSRF(w, r, session) {
return
}
if err := r.ParseForm(); err != nil {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/dashboard")
return
}
action := r.FormValue("action")
switch action {
case "web-settings":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/settings")
return
}
locale := r.FormValue("default_locale")
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 {
jsonInternalError(w, err)
return
}
s.auditLog("web_settings_updated", "", "", s.clientIP(r), "updated by administrator")
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")
return
}
id := r.FormValue("id")
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
var blocked int
if err := tx.QueryRow("SELECT blocked FROM server_users WHERE id=?", id).Scan(&blocked); err != nil {
if err == sql.ErrNoRows {
s.renderWebError(w, r, http.StatusNotFound, "error.badRequest", "/admin/users")
return
}
jsonInternalError(w, err)
return
}
newValue := 1
if blocked != 0 {
newValue = 0
}
if _, err := tx.Exec("UPDATE server_users SET blocked=? WHERE id=?", newValue, id); err != nil {
jsonInternalError(w, err)
return
}
if newValue != 0 {
if _, err := tx.Exec("DELETE FROM server_sessions WHERE scope='user' AND subject_id=?", id); err != nil {
jsonInternalError(w, err)
return
}
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("user_block_changed", id, "", s.clientIP(r), "changed by administrator")
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
case "edit-user":
id, username, email := r.FormValue("id"), strings.TrimSpace(r.FormValue("username")), strings.ToLower(strings.TrimSpace(r.FormValue("email")))
if id == "" || username == "" || email == "" {
s.renderWebError(w, r, http.StatusBadRequest, "error.allFieldsRequired", "/admin/users")
return
}
if _, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", username, email, id); err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
s.renderWebError(w, r, http.StatusConflict, "error.accountTaken", "/admin/users")
return
}
jsonInternalError(w, err)
return
}
s.auditLog("user_updated", id, "", s.clientIP(r), "updated by administrator")
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
case "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 := r.FormValue("id")
password, err := randomSecret(16)
if err != nil {
jsonInternalError(w, err)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
jsonInternalError(w, err)
return
}
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
if _, err := tx.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), id); err != nil {
jsonInternalError(w, err)
return
}
if _, err := tx.Exec("DELETE FROM server_sessions WHERE scope='user' AND subject_id=?", id); err != nil {
jsonInternalError(w, err)
return
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("user_password_reset", id, "", s.clientIP(r), "reset by administrator")
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")
return
}
id := r.FormValue("id")
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
for _, statement := range []string{"DELETE FROM server_sessions WHERE subject_id=? AND scope='user'", "DELETE FROM server_email_tokens WHERE user_id=?", "DELETE FROM server_blob_refs WHERE user_id=?", "DELETE FROM server_idempotency_keys WHERE user_id=?", "DELETE FROM server_tombstones WHERE user_id=?", "DELETE FROM server_revisions WHERE op_id IN (SELECT op_id FROM server_ops WHERE user_id=?)", "DELETE FROM server_ops WHERE user_id=?", "DELETE FROM server_user_devices WHERE user_id=?", "DELETE FROM server_devices WHERE user_id=?", "DELETE FROM server_audit_log WHERE user_id=?", "DELETE FROM server_users WHERE id=?"} {
if _, err := tx.Exec(statement, id); err != nil {
jsonInternalError(w, err)
return
}
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("user_deleted", id, "", s.clientIP(r), "deleted by administrator")
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
case "revoke-device":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/devices")
return
}
id := r.FormValue("id")
if err := s.revokeDevice(id, time.Now().UTC().Format(time.RFC3339)); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("device_revoked", "", id, s.clientIP(r), "revoked by administrator")
http.Redirect(w, r, "/admin/devices", http.StatusSeeOther)
case "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")
return
}
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
for _, key := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_security", "smtp_from", "server_url"} {
if _, err := tx.Exec("INSERT OR REPLACE INTO server_smtp_config (key, value) VALUES (?, ?)", key, r.FormValue(key)); err != nil {
jsonInternalError(w, err)
return
}
}
if pass := r.FormValue("smtp_pass"); pass != "" {
if _, err := tx.Exec("INSERT OR REPLACE INTO server_smtp_config (key, value) VALUES (?, ?)", "smtp_pass", pass); err != nil {
jsonInternalError(w, err)
return
}
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("smtp_settings_updated", "", "", s.clientIP(r), "updated by administrator")
http.Redirect(w, r, "/admin/settings?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")
return
}
if err := s.CleanupRetention(time.Now().UTC()); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("retention_cleanup", "", "", s.clientIP(r), "safe retention cleanup run by administrator")
http.Redirect(w, r, "/admin/storage?flash=cleanup_done", http.StatusSeeOther)
default:
s.renderWebError(w, r, http.StatusBadRequest, "error.badRequest", "/admin/dashboard")
}
}
func (s *Server) handleAdminWebLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
if !s.requireAdminMutation(w, r) {
return
}
if cookie, err := r.Cookie("admin_session"); err == nil {
if err := s.deleteSession(cookie.Value); err != nil {
jsonInternalError(w, err)
return
}
}
s.clearSessionCookies(w, r, sessionScopeAdmin)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}
// handleAdminDiagnosticsJSON is intentionally a separate, authenticated
// download: it contains operational state but never paths, credentials,
// tokens, payloads, or user content.
func (s *Server) handleAdminDiagnosticsJSON(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !s.requireAdminCookie(w, r) {
return
}
stats, err := s.Stats(r.Context())
if err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]interface{}{
"health": s.healthStatus(r.Context()),
"stats": stats,
"limits": map[string]interface{}{
"max_json_body": s.cfg.Limits.MaxJSONBody,
"max_push_operations": s.cfg.Limits.MaxPushOperations,
"max_pull_page": s.cfg.Limits.MaxPullPage,
"max_blob_bytes": s.cfg.Limits.MaxBlobBytes,
},
"web": map[string]interface{}{
"default_locale": s.cfg.Web.DefaultLocale,
"registration_allowed": s.cfg.Web.AllowRegistration,
},
})
}