diff --git a/cmd/verstak-server/server.go b/cmd/verstak-server/server.go
index 3c61cb6..222ff44 100644
--- a/cmd/verstak-server/server.go
+++ b/cmd/verstak-server/server.go
@@ -324,6 +324,9 @@ func (s *Server) routes() *http.ServeMux {
mux.HandleFunc("/api/v1/auth/forgot", s.handleForgot)
mux.HandleFunc("/api/v1/auth/reset", s.handleReset)
mux.HandleFunc("/api/v1/user/devices", s.handleUserDevices)
+ mux.HandleFunc("/login", s.handleUserWebLogin)
+ mux.HandleFunc("/dashboard", s.handleUserDashboard)
+ mux.HandleFunc("/logout", s.handleUserWebLogout)
mux.HandleFunc("/admin/login", s.handleAdminLogin)
mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard)
mux.HandleFunc("/admin/", s.handleAdminAPI)
@@ -1020,6 +1023,185 @@ func (s *Server) handleBlobs(w http.ResponseWriter, r *http.Request) {
}
}
+// ============================================================
+// User web GUI
+// ============================================================
+
+func (s *Server) requireUserWeb(w http.ResponseWriter, r *http.Request) (string, bool) {
+ cookie, err := r.Cookie("user_session")
+ if err != nil {
+ http.Redirect(w, r, "/login", http.StatusFound)
+ return "", false
+ }
+ userID, ok := s.userTokens.Check(cookie.Value)
+ if !ok {
+ http.Redirect(w, r, "/login", http.StatusFound)
+ return "", false
+ }
+ return userID, true
+}
+
+func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case "GET":
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(userLoginHTML))
+ case "POST":
+ if err := r.ParseForm(); err != nil {
+ jsonErr(w, 400, "bad form")
+ return
+ }
+ 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 {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(401)
+ w.Write([]byte("
401 Unauthorized
Try again"))
+ return
+ }
+ tok := s.userTokens.Create(userID)
+ http.SetCookie(w, &http.Cookie{
+ Name: "user_session", Value: tok, Path: "/",
+ HttpOnly: true, SameSite: http.SameSiteLaxMode,
+ MaxAge: 86400,
+ })
+ http.Redirect(w, r, "/dashboard", http.StatusFound)
+ default:
+ jsonErr(w, 405, "method not allowed")
+ }
+}
+
+func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
+ userID, ok := s.requireUserWeb(w, r)
+ if !ok {
+ return
+ }
+ // Get username.
+ var username string
+ s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
+
+ // Get devices.
+ rows, err := s.db.Query(`
+ SELECT d.id, d.name, d.api_key, d.last_seen, 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`, userID)
+ if err != nil {
+ jsonErr(w, 500, err.Error())
+ return
+ }
+ defer rows.Close()
+
+ type dev struct {
+ ID, Name, APIKey, LastSeen, CreatedAt string
+ }
+ var devices []dev
+ for rows.Next() {
+ var d dev
+ var lastSeen sql.NullString
+ if err := rows.Scan(&d.ID, &d.Name, &d.APIKey, &lastSeen, &d.CreatedAt); err != nil {
+ continue
+ }
+ d.LastSeen = lastSeen.String
+ devices = append(devices, d)
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ // Build device rows HTML.
+ deviceRows := ""
+ if len(devices) == 0 {
+ deviceRows = "| Нет подключённых устройств |
"
+ } else {
+ for _, d := range devices {
+ lastSeen := d.LastSeen
+ if lastSeen == "" {
+ lastSeen = "—"
+ }
+ escKey := strings.ReplaceAll(d.APIKey, "'", "\\'")
+ deviceRows += fmt.Sprintf(`
+ | %s |
+ %s |
+ %s |
+
+
+
+ |
+
`, d.Name, d.APIKey, d.APIKey, lastSeen, escKey, d.ID)
+ }
+ }
+
+ html := fmt.Sprintf(`
+
+
+Verstak Sync — %s
+
+
+
+
Verstak Sync
+
%s · Выйти
+
+Устройства
+| Устройство | API-ключ | Последняя активность | |
%s
+
+Новое устройство
+
+
+`, username, username, deviceRows, username)
+ w.Write([]byte(html))
+}
+
+func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, &http.Cookie{
+ Name: "user_session", Value: "", Path: "/",
+ HttpOnly: true, MaxAge: -1,
+ })
+ http.Redirect(w, r, "/login", http.StatusFound)
+}
+
+// ============================================================
+// Admin handlers
+// ============================================================
+
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
@@ -1232,6 +1414,31 @@ func (s *Server) handleAdminAPI(w http.ResponseWriter, r *http.Request) {
// Embedded admin login HTML
// ============================================================
+const userLoginHTML = `
+
+
+Verstak Sync — Вход
+
+
+
+`
+
const adminLoginHTML = `