852 lines
30 KiB
Go
852 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
|
|
}
|
|
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"})
|
|
}
|
|
|
|
func (s *Server) handleAdminPasswordResultSecret(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
if !s.requireAdminMutation(w, r) {
|
|
return
|
|
}
|
|
cookie, err := r.Cookie("admin_session")
|
|
if err != nil {
|
|
jsonErrCode(w, http.StatusForbidden, "session_invalid", "administrator session is required")
|
|
return
|
|
}
|
|
secret := s.takeAdminOneTimeSecret(cookie.Value)
|
|
if secret == "" {
|
|
jsonErrCode(w, http.StatusGone, "one_time_secret_expired", "one-time password is no longer available")
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
|
jsonOK(w, map[string]string{"password": 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,
|
|
},
|
|
})
|
|
}
|