diff --git a/cmd/verstak-server/server.go b/cmd/verstak-server/server.go
index 2bac005..9a7c902 100644
--- a/cmd/verstak-server/server.go
+++ b/cmd/verstak-server/server.go
@@ -12,6 +12,7 @@ import (
"log"
"net"
"net/http"
+ "strconv"
"net/smtp"
"os"
"path/filepath"
@@ -233,6 +234,8 @@ CREATE TABLE IF NOT EXISTS server_users (
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
confirmed INTEGER NOT NULL DEFAULT 0,
+ blocked INTEGER NOT NULL DEFAULT 0,
+ last_seen TEXT,
created_at TEXT NOT NULL
);
@@ -282,6 +285,9 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
return nil, fmt.Errorf("schema: %w", err)
}
}
+ // Migrations for older databases.
+ db.Exec("ALTER TABLE server_users ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0")
+ db.Exec("ALTER TABLE server_users ADD COLUMN last_seen TEXT")
blobsDir := filepath.Join(dataDir, "blobs")
if err := os.MkdirAll(blobsDir, 0750); err != nil {
@@ -331,6 +337,7 @@ func (s *Server) routes() *http.ServeMux {
mux.HandleFunc("/logout", s.handleUserWebLogout)
mux.HandleFunc("/admin/login", s.handleAdminLogin)
mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard)
+ mux.HandleFunc("/admin/users", s.handleAdminUsers)
mux.HandleFunc("/admin/api/stats", s.handleAdminStats)
mux.HandleFunc("/admin/api/smtp/test", s.handleAdminSMTPTest)
mux.HandleFunc("/admin/", s.handleAdminAPI)
@@ -602,13 +609,17 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
// Look up user by username or email.
var userID, hash string
- var confirmed int
- err := s.db.QueryRow("SELECT id, password_hash, confirmed FROM server_users WHERE username=? OR email=?",
- req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed)
+ var confirmed, blocked int
+ err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
+ req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked)
if err != nil {
jsonErr(w, 401, "invalid credentials")
return
}
+ if blocked != 0 {
+ jsonErr(w, 403, "account blocked")
+ return
+ }
if confirmed == 0 {
jsonErr(w, 403, "email not confirmed")
return
@@ -745,7 +756,7 @@ func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
log.Printf("confirm: user %s confirmed email", userID)
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", tokenStr)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.Write([]byte("
Email confirmed
You can now log in.
"))
+ w.Write([]byte(confirmedHTML))
}
func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
@@ -766,13 +777,17 @@ func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
return
}
var userID, hash string
- var confirmed int
- err := s.db.QueryRow("SELECT id, password_hash, confirmed FROM server_users WHERE username=? OR email=?",
- req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed)
+ var confirmed, blocked int
+ err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
+ req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked)
if err != nil {
jsonErr(w, 401, "invalid credentials")
return
}
+ if blocked != 0 {
+ jsonErr(w, 403, "account blocked")
+ return
+ }
if confirmed == 0 {
jsonErr(w, 403, "email not confirmed")
return
@@ -781,6 +796,7 @@ func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 401, "invalid credentials")
return
}
+ s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), userID)
tok := s.userTokens.Create(userID)
jsonOK(w, map[string]string{"token": tok, "user_id": userID})
}
@@ -1195,7 +1211,11 @@ func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
log.Printf("register web: SMTP not configured, confirmation token=%s for user %s", tokenStr, username)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.Write([]byte("Registration successful
Check your email to confirm (or if SMTP not configured, check server logs for the token).
Log in"))
+ regMsg := registrationOKHTML
+ if host == "" {
+ regMsg = registrationAutoHTML
+ }
+ w.Write([]byte(regMsg))
default:
jsonErr(w, 405, "method not allowed")
}
@@ -1214,10 +1234,10 @@ func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
var userID, hash string
- var confirmed int
- err := s.db.QueryRow("SELECT id, password_hash, confirmed FROM server_users WHERE username=? OR email=?",
- username, strings.ToLower(username)).Scan(&userID, &hash, &confirmed)
- if err != nil || confirmed == 0 || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
+ var confirmed, blocked int
+ err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
+ username, strings.ToLower(username)).Scan(&userID, &hash, &confirmed, &blocked)
+ if err != nil || blocked != 0 || confirmed == 0 || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(401)
w.Write([]byte("401 Unauthorized
Try again"))
@@ -1453,6 +1473,7 @@ pre{background:#13131f;border:1px solid #2a2a3c;border-radius:8px;padding:12px;o
@@ -1538,6 +1559,14 @@ function testSMTP(){
w.Write([]byte(html))
}
+func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(adminUsersHTML))
+}
+
func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
@@ -1654,6 +1683,158 @@ func (s *Server) handleAdminAPI(w http.ResponseWriter, r *http.Request) {
}
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
+ case path == "/api/users" && r.Method == "GET":
+ filter := r.URL.Query().Get("filter")
+ sort := r.URL.Query().Get("sort")
+ order := r.URL.Query().Get("order")
+ page, _ := strconv.Atoi(r.URL.Query().Get("page"))
+ perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
+ if page < 1 {
+ page = 1
+ }
+ if perPage < 1 || perPage > 100 {
+ perPage = 20
+ }
+ where := ""
+ var args []interface{}
+ if filter != "" {
+ where = " WHERE u.username LIKE ?"
+ args = append(args, "%"+filter+"%")
+ }
+ validSorts := map[string]string{
+ "username": "u.username",
+ "email": "u.email",
+ "confirmed": "u.confirmed",
+ "blocked": "u.blocked",
+ "created_at": "u.created_at",
+ "last_seen": "u.last_seen",
+ "devices": "devices",
+ }
+ orderClause := "u.created_at DESC"
+ if col, ok := validSorts[sort]; ok {
+ if order != "asc" {
+ order = "desc"
+ }
+ orderClause = col + " " + order
+ }
+ // Count total.
+ var total int
+ countSQL := "SELECT COUNT(*) FROM server_users u" + where
+ s.db.QueryRow(countSQL, args...).Scan(&total)
+ // Fetch page.
+ offset := (page - 1) * perPage
+ sql := `SELECT u.id, u.username, u.email, u.confirmed, u.blocked, u.last_seen, u.created_at,
+ COALESCE((SELECT COUNT(*) FROM server_user_devices ud JOIN server_devices d ON d.id=ud.device_id WHERE ud.user_id=u.id),0) AS devices
+ FROM server_users u` + where + ` ORDER BY ` + orderClause + ` LIMIT ? OFFSET ?`
+ args = append(args, perPage, offset)
+ rows, err := s.db.Query(sql, args...)
+ if err != nil {
+ jsonErr(w, 500, err.Error())
+ return
+ }
+ defer rows.Close()
+ type userRow struct {
+ ID string `json:"id"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+ Confirmed int `json:"confirmed"`
+ Blocked int `json:"blocked"`
+ LastSeen string `json:"last_seen"`
+ CreatedAt string `json:"created_at"`
+ Devices int `json:"devices"`
+ }
+ var users []userRow
+ for rows.Next() {
+ var u userRow
+ var lastSeen *string
+ rows.Scan(&u.ID, &u.Username, &u.Email, &u.Confirmed, &u.Blocked, &lastSeen, &u.CreatedAt, &u.Devices)
+ if lastSeen != nil {
+ u.LastSeen = *lastSeen
+ }
+ users = append(users, u)
+ }
+ jsonOK(w, map[string]interface{}{
+ "users": users,
+ "total": total,
+ "page": page,
+ "per_page": perPage,
+ })
+
+ case strings.HasPrefix(path, "/api/users/") && r.Method == "POST":
+ sub := strings.TrimPrefix(path, "/api/users/")
+ if strings.HasSuffix(sub, "/block") {
+ id := strings.TrimSuffix(sub, "/block")
+ id = strings.TrimSuffix(id, "/")
+ var blocked int
+ s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", id).Scan(&blocked)
+ newVal := 1
+ if blocked != 0 {
+ newVal = 0
+ }
+ s.db.Exec("UPDATE server_users SET blocked=? WHERE id=?", newVal, id)
+ jsonOK(w, map[string]interface{}{"status": "ok", "blocked": newVal})
+ return
+ }
+ if strings.HasSuffix(sub, "/reset-password") {
+ id := strings.TrimSuffix(sub, "/reset-password")
+ id = strings.TrimSuffix(id, "/")
+ b := make([]byte, 12)
+ rand.Read(b)
+ newPass := hex.EncodeToString(b)
+ hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
+ _, err := s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), id)
+ if err != nil {
+ jsonErr(w, 500, err.Error())
+ return
+ }
+ jsonOK(w, map[string]interface{}{"status": "ok", "new_password": newPass})
+ return
+ }
+ if strings.HasSuffix(sub, "/edit") {
+ id := strings.TrimSuffix(sub, "/edit")
+ id = strings.TrimSuffix(id, "/")
+ var req struct {
+ Username string `json:"username"`
+ Email string `json:"email"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ jsonErr(w, 400, "bad json")
+ return
+ }
+ if req.Username == "" || req.Email == "" {
+ jsonErr(w, 400, "username and email required")
+ return
+ }
+ _, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", req.Username, strings.ToLower(req.Email), id)
+ if err != nil {
+ jsonErr(w, 500, err.Error())
+ return
+ }
+ jsonOK(w, map[string]interface{}{"status": "ok"})
+ return
+ }
+ jsonErr(w, 404, "unknown action")
+
+ case strings.HasPrefix(path, "/api/users/") && r.Method == "DELETE":
+ id := strings.TrimPrefix(path, "/api/users/")
+ id = strings.TrimSuffix(id, "/")
+ // Get user devices to delete.
+ rows, _ := s.db.Query("SELECT device_id FROM server_user_devices WHERE user_id=?", id)
+ var deviceIDs []string
+ for rows.Next() {
+ var did string
+ rows.Scan(&did)
+ deviceIDs = append(deviceIDs, did)
+ }
+ rows.Close()
+ for _, did := range deviceIDs {
+ s.db.Exec("DELETE FROM server_devices WHERE id=?", did)
+ }
+ s.db.Exec("DELETE FROM server_user_devices WHERE user_id=?", id)
+ s.db.Exec("DELETE FROM server_email_tokens WHERE user_id=?", id)
+ s.db.Exec("DELETE FROM server_users WHERE id=?", id)
+ jsonOK(w, map[string]interface{}{"status": "deleted"})
+
default:
jsonErr(w, 404, "not found")
}
@@ -1739,3 +1920,243 @@ button:hover{background:#4f46e5}