verstak/cmd/verstak-server/server.go

2163 lines
76 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"strconv"
"net/smtp"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
_ "github.com/mattn/go-sqlite3"
)
var passwordRE = regexp.MustCompile(`^[A-Za-z0-9]+$`)
// ============================================================
// Config
// ============================================================
type AdminUser struct {
Username string `yaml:"username"`
PasswordHash string `yaml:"password_hash"`
}
type Config struct {
Port int `yaml:"port"`
Admin []AdminUser `yaml:"admin"`
mu sync.Mutex
path string
}
func LoadConfig(dataDir string) (*Config, error) {
path := filepath.Join(dataDir, "config.yml")
cfg := &Config{
Port: 47732,
Admin: nil,
path: path,
}
data, err := os.ReadFile(path)
if err == nil {
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
}
return cfg, nil
}
func (c *Config) Save() error {
c.mu.Lock()
defer c.mu.Unlock()
data, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(c.path, data, 0640)
}
func (c *Config) SetAdmin(username, password string) error {
c.mu.Lock()
defer c.mu.Unlock()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
user := AdminUser{Username: username, PasswordHash: string(hash)}
// Replace existing or append.
for i, u := range c.Admin {
if u.Username == username {
c.Admin[i] = user
return c.saveLocked()
}
}
c.Admin = append(c.Admin, user)
return c.saveLocked()
}
func (c *Config) CheckAdmin(username, password string) bool {
c.mu.Lock()
defer c.mu.Unlock()
for _, u := range c.Admin {
if u.Username == username {
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil {
return true
}
}
}
return false
}
func (c *Config) saveLocked() error {
data, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(c.path, data, 0640)
}
// ============================================================
// Token
// ============================================================
type tokenStore struct {
mu sync.Mutex
tokens map[string]time.Time
}
func newTokenStore() *tokenStore {
return &tokenStore{tokens: make(map[string]time.Time)}
}
func (ts *tokenStore) Create() string {
ts.mu.Lock()
defer ts.mu.Unlock()
b := make([]byte, 16)
rand.Read(b)
tok := hex.EncodeToString(b)
ts.tokens[tok] = time.Now().Add(24 * time.Hour)
return tok
}
func (ts *tokenStore) Check(tok string) bool {
ts.mu.Lock()
defer ts.mu.Unlock()
exp, ok := ts.tokens[tok]
if !ok {
return false
}
if time.Now().After(exp) {
delete(ts.tokens, tok)
return false
}
return true
}
// userTokenStore embeds tokenStore but also tracks the user_id per token.
type userTokenStore struct {
mu sync.Mutex
tokens map[string]userTokenEntry
}
type userTokenEntry struct {
UserID string
ExpiresAt time.Time
}
func newUserTokenStore() *userTokenStore {
return &userTokenStore{tokens: make(map[string]userTokenEntry)}
}
func (uts *userTokenStore) Create(userID string) string {
uts.mu.Lock()
defer uts.mu.Unlock()
b := make([]byte, 16)
rand.Read(b)
tok := hex.EncodeToString(b)
uts.tokens[tok] = userTokenEntry{UserID: userID, ExpiresAt: time.Now().Add(24 * time.Hour)}
return tok
}
func (uts *userTokenStore) Check(tok string) (string, bool) {
uts.mu.Lock()
defer uts.mu.Unlock()
entry, ok := uts.tokens[tok]
if !ok {
return "", false
}
if time.Now().After(entry.ExpiresAt) {
delete(uts.tokens, tok)
return "", false
}
return entry.UserID, true
}
// ============================================================
// Server DB schema
// ============================================================
const serverSchema = `
CREATE TABLE IF NOT EXISTS server_devices (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
api_key TEXT NOT NULL UNIQUE,
last_seen TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_revisions (
rev INTEGER PRIMARY KEY AUTOINCREMENT,
op_id TEXT NOT NULL,
device_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS server_ops (
op_id TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
op_type TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL,
pushed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS server_blobs (
sha256 TEXT PRIMARY KEY,
size INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_smtp_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
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
);
CREATE TABLE IF NOT EXISTS server_email_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
purpose TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_user_devices (
user_id TEXT NOT NULL,
device_id TEXT NOT NULL,
PRIMARY KEY (user_id, device_id)
);
`
// ============================================================
// Server
// ============================================================
type Server struct {
db *sql.DB
cfg *Config
tokens *tokenStore
userTokens *userTokenStore
blobsDir string
mux *http.ServeMux
}
func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc", dbPath))
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
db.SetMaxOpenConns(1)
// Run schema.
for _, stmt := range strings.Split(serverSchema, ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := db.Exec(stmt); err != nil {
db.Close()
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 {
db.Close()
return nil, err
}
s := &Server{
db: db,
cfg: cfg,
tokens: newTokenStore(),
userTokens: newUserTokenStore(),
blobsDir: blobsDir,
}
s.mux = s.routes()
return s, nil
}
func (s *Server) Close() error {
return s.db.Close()
}
func (s *Server) ListenAndServe(addr string) error {
return http.ListenAndServe(addr, s.mux)
}
// ============================================================
// Routes
// ============================================================
func (s *Server) routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/health", s.handleHealth)
mux.HandleFunc("/api/v1/device/register", s.handleDeviceRegister)
mux.HandleFunc("/api/v1/sync/push", s.handleSyncPush)
mux.HandleFunc("/api/v1/sync/pull", s.handleSyncPull)
mux.HandleFunc("/api/v1/blobs/", s.handleBlobs)
mux.HandleFunc("/api/v1/auth/register", s.handleRegister)
mux.HandleFunc("/api/v1/auth/confirm", s.handleConfirm)
mux.HandleFunc("/api/v1/auth/login", s.handleUserLogin)
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("/register", s.handleUserWebRegister)
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/users", s.handleAdminUsers)
mux.HandleFunc("/admin/api/stats", s.handleAdminStats)
mux.HandleFunc("/admin/api/smtp/test", s.handleAdminSMTPTest)
mux.HandleFunc("/admin/", s.handleAdminAPI)
mux.HandleFunc("/", s.handleNotFound)
return mux
}
// ============================================================
// Helpers
// ============================================================
func jsonOK(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func (s *Server) requireAPIKey(w http.ResponseWriter, r *http.Request) bool {
key := r.Header.Get("Authorization")
key = strings.TrimPrefix(key, "Bearer ")
if key == "" {
key = r.URL.Query().Get("api_key")
}
if key == "" {
jsonErr(w, 401, "API key required")
return false
}
var count int
err := s.db.QueryRow("SELECT COUNT(*) FROM server_devices WHERE api_key=?", key).Scan(&count)
if err != nil || count == 0 {
jsonErr(w, 401, "invalid API key")
return false
}
return true
}
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
cookie, err := r.Cookie("session")
if err != nil || !s.tokens.Check(cookie.Value) {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return false
}
return true
}
// ============================================================
// SMTP Config
// ============================================================
func (s *Server) smtpGet(key string) string {
var val string
s.db.QueryRow("SELECT value FROM server_smtp_config WHERE key=?", key).Scan(&val)
return val
}
func (s *Server) smtpSet(key, val string) error {
_, err := s.db.Exec("INSERT OR REPLACE INTO server_smtp_config (key, value) VALUES (?, ?)", key, val)
return err
}
func sel(v, want string) string {
if v == want {
return " selected"
}
return ""
}
func (s *Server) smtpConnect(host, port, user, pass, security string) (*smtp.Client, error) {
addr := net.JoinHostPort(host, port)
switch security {
case "tls":
tlsCfg := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return nil, fmt.Errorf("tls dial: %w", err)
}
cl, err := smtp.NewClient(conn, host)
if err != nil {
conn.Close()
return nil, fmt.Errorf("smtp client: %w", err)
}
return cl, nil
default:
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return nil, fmt.Errorf("connect: %w", err)
}
cl, err := smtp.NewClient(conn, host)
if err != nil {
conn.Close()
return nil, fmt.Errorf("smtp client: %w", err)
}
if security != "none" {
if ok, _ := cl.Extension("STARTTLS"); ok {
tlsCfg := &tls.Config{ServerName: host}
if err := cl.StartTLS(tlsCfg); err != nil {
cl.Close()
return nil, fmt.Errorf("starttls: %w", err)
}
}
}
return cl, nil
}
}
func (s *Server) smtpSendMsg(cl *smtp.Client, user, pass, host, from, to string, msg []byte) error {
if user != "" {
auth := smtp.PlainAuth("", user, pass, host)
if err := cl.Auth(auth); err != nil {
return fmt.Errorf("auth: %w", err)
}
}
if err := cl.Mail(from); err != nil {
return fmt.Errorf("mail from: %w", err)
}
if err := cl.Rcpt(to); err != nil {
return fmt.Errorf("rcpt: %w", err)
}
w, err := cl.Data()
if err != nil {
return fmt.Errorf("data: %w", err)
}
if _, err := w.Write(msg); err != nil {
w.Close()
return fmt.Errorf("write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("send: %w", err)
}
return nil
}
func (s *Server) smtpSend(to, subject, body string) error {
host := s.smtpGet("smtp_host")
port := s.smtpGet("smtp_port")
user := s.smtpGet("smtp_user")
pass := s.smtpGet("smtp_pass")
from := s.smtpGet("smtp_from")
security := s.smtpGet("smtp_security")
if host == "" || port == "" || from == "" {
err := fmt.Errorf("SMTP not configured")
log.Printf("smtp: %v (to=%s)", err, to)
return err
}
log.Printf("smtp: sending to %s via %s:%s (security=%s)", to, host, port, security)
msg := []byte("From: " + from + "\r\n" +
"To: " + to + "\r\n" +
"Subject: " + subject + "\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"\r\n" + body + "\r\n")
cl, err := s.smtpConnect(host, port, user, pass, security)
if err != nil {
log.Printf("smtp: connect error: %v", err)
return err
}
defer cl.Close()
if err := s.smtpSendMsg(cl, user, pass, host, from, to, msg); err != nil {
log.Printf("smtp: send error: %v", err)
return err
}
log.Printf("smtp: sent OK to %s", to)
return nil
}
func (s *Server) smtpTest(host, port, user, pass, security, from, to string) error {
if host == "" || port == "" || from == "" {
return fmt.Errorf("SMTP not configured")
}
msg := []byte("From: " + from + "\r\nTo: " + to + "\r\nSubject: Test from Verstak Sync\r\n\r\nThis is a test email from Verstak Sync Server.\r\n")
cl, err := s.smtpConnect(host, port, user, pass, security)
if err != nil {
return err
}
defer cl.Close()
return s.smtpSendMsg(cl, user, pass, host, from, to, msg)
}
// ============================================================
// User helpers
// ============================================================
func validatePassword(password string) string {
if len(password) < 8 {
return "Password must be at least 8 characters"
}
if !passwordRE.MatchString(password) {
return "Password must contain only Latin letters and digits"
}
hasLetter := false
hasDigit := false
for _, ch := range password {
if ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z' {
hasLetter = true
}
if ch >= '0' && ch <= '9' {
hasDigit = true
}
}
if !hasLetter || !hasDigit {
return "Password must contain both letters and digits"
}
return ""
}
func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) (string, bool) {
key := r.Header.Get("Authorization")
key = strings.TrimPrefix(key, "Bearer ")
if key == "" {
jsonErr(w, 401, "authorization required")
return "", false
}
userID, ok := s.userTokens.Check(key)
if !ok {
jsonErr(w, 401, "invalid or expired token")
return "", false
}
return userID, true
}
// ============================================================
// Handlers
// ============================================================
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte("Verstak Sync Server\n"))
return
}
jsonErr(w, 404, "not found")
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
jsonOK(w, map[string]interface{}{
"status": "ok",
"version": "verstak-server/v1",
"time": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Name string `json:"name"`
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.Name == "" {
jsonErr(w, 400, "name required")
return
}
if req.Username == "" || req.Password == "" {
jsonErr(w, 401, "username and password required")
return
}
// Look up user by username or email.
var userID, hash string
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
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
jsonErr(w, 401, "invalid credentials")
return
}
b := make([]byte, 20)
rand.Read(b)
apiKey := hex.EncodeToString(b)
deviceID := apiKey[:12]
now := time.Now().UTC().Format(time.RFC3339)
_, err = s.db.Exec(
"INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)",
deviceID, req.Name, apiKey, now, now,
)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
// Link device to user.
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
jsonOK(w, map[string]interface{}{
"device_id": deviceID,
"api_key": apiKey,
})
}
// ============================================================
// Auth / User handlers
// ============================================================
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.Username == "" || req.Email == "" || req.Password == "" {
jsonErr(w, 400, "username, email and password required")
return
}
if err := validatePassword(req.Password); err != "" {
jsonErr(w, 400, err)
return
}
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
jsonErr(w, 400, "invalid email")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
jsonErr(w, 500, "internal error")
return
}
now := time.Now().UTC().Format(time.RFC3339)
id := make([]byte, 12)
rand.Read(id)
userID := hex.EncodeToString(id)
_, err = s.db.Exec(
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)",
userID, req.Username, strings.ToLower(req.Email), string(hash), now,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
jsonErr(w, 409, "username or email already taken")
return
}
jsonErr(w, 500, err.Error())
return
}
// Confirmation token.
tok := make([]byte, 24)
rand.Read(tok)
tokenStr := hex.EncodeToString(tok)
exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)",
tokenStr, userID, exp, now)
// Try to send email.
host := s.smtpGet("smtp_host")
if host != "" {
srvURL := s.smtpGet("server_url")
var confirmURL string
if srvURL != "" {
confirmURL = fmt.Sprintf("%s/confirm?token=%s", srvURL, tokenStr)
} else {
confirmURL = fmt.Sprintf("/api/v1/auth/confirm?token=%s", tokenStr)
}
body := fmt.Sprintf("Welcome to Verstak Sync!\n\nPlease confirm your email by clicking:\n%s\n\nIf you did not register, ignore this message.", confirmURL)
if err := s.smtpSend(req.Email, "Confirm your Verstak Sync account", body); err != nil {
log.Printf("register: failed to send confirm email: %v", err)
}
} else {
log.Printf("register: SMTP not configured, confirmation token=%s for user %s", tokenStr, req.Username)
}
jsonOK(w, map[string]string{"status": "confirmation_sent"})
}
func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
jsonErr(w, 405, "GET required")
return
}
tokenStr := r.URL.Query().Get("token")
if tokenStr == "" {
jsonErr(w, 400, "token required")
return
}
var userID, expiresAt string
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='confirm'",
tokenStr).Scan(&userID, &expiresAt)
if err != nil {
jsonErr(w, 400, "invalid or expired token")
return
}
exp, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || time.Now().After(exp) {
jsonErr(w, 400, "token expired")
return
}
s.db.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", userID)
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(confirmedHTML))
}
func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.Username == "" || req.Password == "" {
jsonErr(w, 400, "username and password required")
return
}
var userID, hash string
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
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
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})
}
func (s *Server) handleForgot(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.Email == "" {
jsonErr(w, 400, "email required")
return
}
var userID string
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", strings.ToLower(req.Email)).Scan(&userID)
if err != nil {
jsonOK(w, map[string]string{"status": "if email exists, reset link sent"})
return
}
tok := make([]byte, 24)
rand.Read(tok)
tokenStr := hex.EncodeToString(tok)
exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339)
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)",
tokenStr, userID, exp, now)
host := s.smtpGet("smtp_host")
if host != "" {
resetURL := fmt.Sprintf("%s/reset?token=%s", s.smtpGet("server_url"), tokenStr)
if resetURL == "" {
resetURL = fmt.Sprintf("/api/v1/auth/reset?token=%s", tokenStr)
}
body := fmt.Sprintf("Reset your Verstak Sync password:\n\n%s\n\nThis link expires in 1 hour.", resetURL)
s.smtpSend(req.Email, "Verstak Sync password reset", body)
}
jsonOK(w, map[string]string{"status": "if email exists, reset link sent"})
}
func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Token string `json:"token"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.Token == "" || req.NewPassword == "" {
jsonErr(w, 400, "token and new_password required")
return
}
if err := validatePassword(req.NewPassword); err != "" {
jsonErr(w, 400, err)
return
}
var userID, expiresAt string
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
req.Token).Scan(&userID, &expiresAt)
if err != nil {
jsonErr(w, 400, "invalid or expired token")
return
}
exp, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || time.Now().After(exp) {
jsonErr(w, 400, "token expired")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
jsonErr(w, 500, "internal error")
return
}
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", req.Token)
jsonOK(w, map[string]string{"status": "password reset"})
}
func (s *Server) handleUserDevices(w http.ResponseWriter, r *http.Request) {
userID, ok := s.requireUser(w, r)
if !ok {
return
}
if r.Method != "GET" {
jsonErr(w, 405, "GET required")
return
}
rows, err := s.db.Query(`
SELECT d.id, d.name, 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 deviceDTO struct {
ID string `json:"id"`
Name string `json:"name"`
LastSeen string `json:"last_seen"`
CreatedAt string `json:"created_at"`
}
var devices []deviceDTO
for rows.Next() {
var d deviceDTO
var lastSeen sql.NullString
if err := rows.Scan(&d.ID, &d.Name, &lastSeen, &d.CreatedAt); err != nil {
continue
}
d.LastSeen = lastSeen.String
devices = append(devices, d)
}
if devices == nil {
devices = []deviceDTO{}
}
jsonOK(w, map[string]interface{}{"devices": devices})
}
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
if !s.requireAPIKey(w, r) {
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
DeviceID string `json:"device_id"`
Ops []struct {
OpID string `json:"op_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"`
} `json:"ops"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON: "+err.Error())
return
}
var accepted []string
for _, op := range req.Ops {
if op.OpID == "" || op.EntityType == "" || op.EntityID == "" || op.OpType == "" {
continue
}
_, err := s.db.Exec(
`INSERT OR IGNORE INTO server_ops (op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
op.OpID, req.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON, op.CreatedAt,
)
if err != nil {
continue
}
// Assign revision.
res, err := s.db.Exec(
"INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)",
op.OpID, req.DeviceID,
)
if err != nil {
continue
}
rev, _ := res.LastInsertId()
_ = rev
accepted = append(accepted, op.OpID)
}
jsonOK(w, map[string]interface{}{
"accepted": accepted,
"count": len(accepted),
})
}
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
if !s.requireAPIKey(w, r) {
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
SinceRevision int `json:"since_revision"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
// Get current server revision.
var serverRev int
s.db.QueryRow("SELECT COALESCE(MAX(rev), 0) FROM server_revisions").Scan(&serverRev)
// Get ops since the requested revision.
rows, err := s.db.Query(`
SELECT so.op_id, so.device_id, so.entity_type, so.entity_id, so.op_type, so.payload_json, so.created_at
FROM server_ops so
JOIN server_revisions sr ON sr.op_id = so.op_id
WHERE sr.rev > ?
ORDER BY sr.rev`, req.SinceRevision)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
defer rows.Close()
type opDTO struct {
OpID string `json:"op_id"`
DeviceID string `json:"device_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"`
}
var ops []opDTO
for rows.Next() {
var o opDTO
if err := rows.Scan(&o.OpID, &o.DeviceID, &o.EntityType, &o.EntityID, &o.OpType, &o.PayloadJSON, &o.CreatedAt); err != nil {
continue
}
ops = append(ops, o)
}
jsonOK(w, map[string]interface{}{
"server_revision": serverRev,
"ops": ops,
})
}
func (s *Server) handleBlobs(w http.ResponseWriter, r *http.Request) {
if !s.requireAPIKey(w, r) {
return
}
switch r.Method {
case "POST":
// Upload: accept multipart file, store by SHA-256.
if err := r.ParseMultipartForm(200 << 20); err != nil {
jsonErr(w, 400, "multipart error: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
jsonErr(w, 400, "file field required")
return
}
defer file.Close()
// Read content and compute SHA-256.
data, err := io.ReadAll(file)
if err != nil {
jsonErr(w, 500, "read error")
return
}
hash := sha256.Sum256(data)
shaHex := hex.EncodeToString(hash[:])
// Store at blobs/ab/cd/sha256.
blobDir := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4])
if err := os.MkdirAll(blobDir, 0750); err != nil {
jsonErr(w, 500, "mkdir error")
return
}
blobPath := filepath.Join(blobDir, shaHex)
if err := os.WriteFile(blobPath, data, 0640); err != nil {
jsonErr(w, 500, "write error")
return
}
_ = header
// Record in blobs table.
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("INSERT OR IGNORE INTO server_blobs (sha256, size, created_at) VALUES (?, ?, ?)",
shaHex, len(data), now)
jsonOK(w, map[string]interface{}{
"sha256": shaHex,
"size": len(data),
})
case "GET":
// Download: GET /api/v1/blobs/{sha256}
shaHex := strings.TrimPrefix(r.URL.Path, "/api/v1/blobs/")
if len(shaHex) != 64 {
jsonErr(w, 400, "invalid SHA-256")
return
}
blobPath := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4], shaHex)
if _, err := os.Stat(blobPath); os.IsNotExist(err) {
jsonErr(w, 404, "blob not found")
return
}
data, err := os.ReadFile(blobPath)
if err != nil {
jsonErr(w, 500, "read error")
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename=\""+shaHex+"\"")
w.Write(data)
default:
jsonErr(w, 405, "method not allowed")
}
}
// ============================================================
// 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) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(userRegisterHTML))
case "POST":
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(400)
w.Write([]byte("<html><body><h1>400 Bad request</h1><a href='/register'>Back</a></body></html>"))
return
}
username := r.FormValue("username")
email := r.FormValue("email")
password := r.FormValue("password")
if username == "" || email == "" || password == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(400)
w.Write([]byte("<html><body><h1>All fields required</h1><a href='/register'>Back</a></body></html>"))
return
}
if err := validatePassword(password); err != "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(400)
w.Write([]byte("<html><body><h1>" + err + "</h1><a href='/register'>Back</a></body></html>"))
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
w.WriteHeader(500)
w.Write([]byte("<html><body><h1>Internal error</h1><a href='/register'>Back</a></body></html>"))
return
}
now := time.Now().UTC().Format(time.RFC3339)
id := make([]byte, 12)
rand.Read(id)
userID := hex.EncodeToString(id)
_, err = s.db.Exec(
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)",
userID, username, strings.ToLower(email), string(hash), now,
)
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if strings.Contains(err.Error(), "UNIQUE") {
w.WriteHeader(409)
w.Write([]byte("<html><body><h1>Username or email already taken</h1><a href='/register'>Back</a></body></html>"))
} else {
w.WriteHeader(500)
w.Write([]byte("<html><body><h1>"+err.Error()+"</h1><a href='/register'>Back</a></body></html>"))
}
return
}
// Confirmation token.
tok := make([]byte, 24)
rand.Read(tok)
tokenStr := hex.EncodeToString(tok)
exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)",
tokenStr, userID, exp, now)
// Try to send email.
host := s.smtpGet("smtp_host")
if host != "" {
srvURL := s.smtpGet("server_url")
var confirmURL string
if srvURL != "" {
confirmURL = fmt.Sprintf("%s/api/v1/auth/confirm?token=%s", srvURL, tokenStr)
} else {
confirmURL = fmt.Sprintf("http://%s/api/v1/auth/confirm?token=%s", r.Host, tokenStr)
}
body := fmt.Sprintf("Welcome to Verstak Sync!\n\nPlease confirm your email by clicking:\n%s\n\nIf you did not register, ignore this message.", confirmURL)
if err := s.smtpSend(email, "Confirm your Verstak Sync account", body); err != nil {
log.Printf("register web: failed to send confirm email: %v", err)
}
} else {
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")
regMsg := registrationOKHTML
if host == "" {
regMsg = registrationAutoHTML
}
w.Write([]byte(regMsg))
default:
jsonErr(w, 405, "method not allowed")
}
}
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, 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("<html><body><h1>401 Unauthorized</h1><a href='/login'>Try again</a></body></html>"))
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 = "<tr><td colspan='4' style='color:#666;text-align:center;padding:24px'>Нет подключённых устройств</td></tr>"
} else {
for _, d := range devices {
lastSeen := d.LastSeen
if lastSeen == "" {
lastSeen = "—"
}
escKey := strings.ReplaceAll(d.APIKey, "'", "\\'")
deviceRows += fmt.Sprintf(`<tr>
<td>%s</td>
<td class="key-cell" title="%s">%s</td>
<td>%s</td>
<td>
<button class="btn copy-btn" onclick="copyKey('%s',this)">Копировать</button>
<button class="btn btn-danger" onclick="delDevice('%s')">Отключить</button>
</td>
</tr>`, d.Name, d.APIKey, d.APIKey, lastSeen, escKey, d.ID)
}
}
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync — %s</title>
<style>
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:800px;margin:0 auto}
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
h2{margin-top:24px;font-size:16px}
table{width:100%;border-collapse:collapse;margin-top:8px}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
th{font-size:12px;color:#888;text-transform:uppercase}
.key-cell{max-width:300px;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:12px;color:#b0b0c0}
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
.btn:hover{background:#222233}
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
.btn-primary:hover{background:#4f46e5}
.btn-danger{color:#ff6b6b;border-color:#4a2222}
.btn-danger:hover{background:#3a2222}
.copy-btn{padding:2px 8px;font-size:11px}
input{font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;margin-right:8px;flex:1}
input:focus{outline:none;border-color:#6366f1}
.top{display:flex;justify-content:space-between;align-items:center}
a{color:#6366f1}
</style>
</head><body>
<div class="top">
<h1>Verstak Sync</h1>
<span>%s · <a href="/logout">Выйти</a></span>
</div>
<h2>Устройства</h2>
<table><tr><th>Устройство</th><th>API-ключ</th><th>Последняя активность</th><th></th></tr>%s</table>
<h2>Новое устройство</h2>
<form action="/api/v1/device/register" method="POST" style="display:flex;gap:8px;margin-top:8px"
onsubmit="event.preventDefault();var f=this;fetch('/api/v1/device/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:f.name.value,username:'%s',password:document.getElementById('regpass').value})}).then(r=>r.json()).then(d=>{if(d.api_key){f.name.value='';location.reload()}else{alert(d.error||'error')}})">
<input name="name" placeholder="Название устройства" required>
<input type="hidden" id="regpass" value="">
<button class="btn btn-primary" type="button" onclick="var p=prompt('Ваш пароль:');if(p){document.getElementById('regpass').value=p;this.form.requestSubmit()}">Подключить</button>
</form>
<script>
function copyKey(key,btn){
navigator.clipboard.writeText(key).then(()=>{
var old=btn.textContent;btn.textContent='Скопировано';btn.style.color='#4ade80'
setTimeout(function(){btn.textContent=old;btn.style.color=''},1500)
})
}
function delDevice(id){
if(!confirm('Отключить устройство?'))return
fetch('/admin/api/keys/'+id,{method:'DELETE'}).then(()=>location.reload())
}
</script>
</body></html>`, 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":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(adminLoginHTML))
case "POST":
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
return
}
user := r.FormValue("username")
pass := r.FormValue("password")
if !s.cfg.CheckAdmin(user, pass) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(401)
w.Write([]byte("<html><body><h1>401 Unauthorized</h1><a href='/admin/login'>Try again</a></body></html>"))
return
}
tok := s.tokens.Create()
http.SetCookie(w, &http.Cookie{
Name: "session", Value: tok, Path: "/admin",
HttpOnly: true, SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
default:
jsonErr(w, 405, "method not allowed")
}
}
func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Fetch data for dashboard.
var deviceCount, opsCount int
s.db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&deviceCount)
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
// Load SMTP config for display.
smtpHost := s.smtpGet("smtp_host")
smtpPort := s.smtpGet("smtp_port")
smtpUser := s.smtpGet("smtp_user")
smtpFrom := s.smtpGet("smtp_from")
smtpSecurity := s.smtpGet("smtp_security")
srvURL := s.smtpGet("server_url")
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync — Admin</title>
<style>
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:860px;margin:0 auto}
a{color:#6366f1}
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
h2{margin-top:24px;font-size:16px}
.stat{background:#1a1a28;border:1px solid #2a2a3c;padding:12px 16px;border-radius:8px;margin:8px 0}
table{width:100%;border-collapse:collapse;margin-top:8px}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
th{font-size:12px;color:#888;text-transform:uppercase}
.key-cell{max-width:360px;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:12px;color:#b0b0c0}
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
.btn:hover{background:#222233}
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
.btn-primary:hover{background:#4f46e5}
.btn-danger{color:#ff6b6b;border-color:#4a2222}
.btn-danger:hover{background:#3a2222}
.copy-btn{padding:2px 8px;font-size:11px;margin-left:6px}
input{font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;margin:0;box-sizing:border-box}
input:focus{outline:none;border-color:#6366f1}
.form-row{display:flex;gap:8px;margin-bottom:8px;align-items:center}
.form-row label{font-size:12px;color:#888;min-width:80px;flex-shrink:0}
.form-row input{flex:1}
.toolbar{display:flex;gap:8px;margin:16px 0;flex-wrap:wrap}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100}
.modal{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:24px;width:420px;max-width:90vw;position:relative;max-height:80vh;overflow-y:auto}
.modal h2{margin-top:0}
.modal-close{position:absolute;top:10px;right:14px;font-size:20px;cursor:pointer;background:none;border:none;color:#888}
.modal-close:hover{color:#e4e4ef}
pre{background:#13131f;border:1px solid #2a2a3c;border-radius:8px;padding:12px;overflow-x:auto;white-space:pre-wrap}
</style>
</head><body>
<h1>Verstak Sync Server</h1>
<div style="display:flex;gap:20px;flex-wrap:wrap">
<div class="stat" style="margin:0"><strong>Устройств:</strong> <span id="dev-count">0</span></div>
<div class="stat" style="margin:0"><strong>Операций:</strong> <span id="op-count">0</span></div>
</div>
<div class="toolbar">
<button class="btn btn-primary" onclick="openSMTP()">Настройка SMTP</button>
<a href="/admin/users" style="text-decoration:none"><button class="btn" type="button">Пользователи</button></a>
<button class="btn" onclick="openHealth()">Health check</button>
</div>
<h2>API-ключи</h2>
<div id="keys"></div>
<script>
fetch('/admin/api/keys').then(r=>r.json()).then(keys=>{
const div=document.getElementById('keys')
if(!keys.length){div.innerHTML='<p>Нет ключей</p>';return}
div.innerHTML='<table><tr><th>Устройство</th><th>API-ключ</th><th></th><th></th></tr>'+
keys.map(k=>'<tr><td>'+k.name+'</td><td class="key-cell" title="'+k.api_key+'">'+k.api_key+'</td>'+
'<td><button class="btn copy-btn" onclick="copyKey(\''+k.api_key+'\',this)">Копировать</button></td>'+
'<td><button class="btn btn-danger" onclick="delKey(\''+k.id+'\')">Удалить</button></td></tr>').join('')+'</table>'
document.getElementById('dev-count').textContent=keys.length
})
fetch('/admin/api/stats').then(r=>r.json()).then(stats=>{
document.getElementById('op-count').textContent=stats.ops||'0'
})
function copyKey(key,btn){
navigator.clipboard.writeText(key).then(()=>{
var old=btn.textContent;btn.textContent='Скопировано';btn.style.color='#4ade80'
setTimeout(function(){btn.textContent=old;btn.style.color=''},1500)
})
}
function delKey(id){if(confirm('Удалить ключ?'))fetch('/admin/api/keys/'+id,{method:'DELETE'}).then(()=>location.reload())}
function openSMTP(){document.getElementById('smtp-modal').style.display='flex';document.getElementById('smtp-test-result').textContent=''}
function closeSMTP(e){if(!e||e.target.id==='smtp-modal')document.getElementById('smtp-modal').style.display='none'}
function openHealth(){var m=document.getElementById('health-modal');m.style.display='flex';document.getElementById('health-result').textContent='Загрузка...';fetch('/api/v1/health').then(function(r){return r.text()}).then(function(t){document.getElementById('health-result').textContent=t})}
function closeHealth(e){if(!e||e.target.id==='health-modal')document.getElementById('health-modal').style.display='none'}
function testSMTP(){
var f=document.querySelector('#smtp-modal form')
var fd=new FormData(f)
var obj={};for(var e of fd.entries()){obj[e[0]]=e[1]}
var r=document.getElementById('smtp-test-result')
r.textContent='⏳ Тестируем...';r.style.color='#888'
fetch('/admin/api/smtp/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(obj)}).then(function(r2){return r2.json()}).then(function(d){
r.textContent=d.ok?'✓ Тест пройден':'✗ '+d.error
r.style.color=d.ok?'#4ade80':'#ff6b6b'
}).catch(function(e){r.textContent='✗ '+e;r.style.color='#ff6b6b'})
}
</script>
<h3>Новый ключ</h3>
<form action="/admin/api/keys" method="POST" style="display:flex;gap:8px">
<input name="name" placeholder="Название устройства" required style="flex:1">
<button class="btn btn-primary">Создать</button>
</form>
<div id="smtp-modal" class="modal-overlay" style="display:none" onclick="closeSMTP(event)">
<div class="modal">
<button class="modal-close" onclick="closeSMTP()">&times;</button>
<h2>SMTP (для писем)</h2>
<form action="/admin/api/smtp" method="POST">
<div class="form-row"><label>Сервер</label><input name="smtp_host" value="` + smtpHost + `" placeholder="smtp.example.com"></div>
<div class="form-row"><label>Порт</label><input name="smtp_port" value="` + smtpPort + `" placeholder="587"></div>
<div class="form-row"><label>Тип</label><select name="smtp_security" style="font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;flex:1;box-sizing:border-box">
<option value="starttls"` + sel(smtpSecurity, "starttls") + `>STARTTLS</option>
<option value="tls"` + sel(smtpSecurity, "tls") + `>TLS</option>
<option value="none"` + sel(smtpSecurity, "none") + `>Без шифрования</option>
</select></div>
<div class="form-row"><label>Логин</label><input name="smtp_user" value="` + smtpUser + `" placeholder="user@example.com"></div>
<div class="form-row"><label>Пароль</label><input type="password" name="smtp_pass" placeholder="••••••••"></div>
<div class="form-row"><label>От кого</label><input name="smtp_from" value="` + smtpFrom + `" placeholder="noreply@example.com"></div>
<div class="form-row"><label>URL сервера</label><input name="server_url" value="` + srvURL + `" placeholder="https://example.com:47732"></div>
<div style="margin-top:12px;display:flex;gap:8px;align-items:center">
<button class="btn btn-primary">Сохранить SMTP</button>
<button class="btn" type="button" onclick="testSMTP()">Test</button>
<span id="smtp-test-result" style="font-size:12px"></span>
</div>
</form>
</div>
</div>
<div id="health-modal" class="modal-overlay" style="display:none" onclick="closeHealth(event)">
<div class="modal">
<button class="modal-close" onclick="closeHealth()">&times;</button>
<h2>Health check</h2>
<pre id="health-result">Загрузка...</pre>
</div>
</div>
</body></html>`)
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
}
var opsCount int
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
jsonOK(w, map[string]int{"ops": opsCount})
}
func (s *Server) handleAdminSMTPTest(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var req struct {
Host string `json:"smtp_host"`
Port string `json:"smtp_port"`
User string `json:"smtp_user"`
Pass string `json:"smtp_pass"`
Security string `json:"smtp_security"`
From string `json:"smtp_from"`
To string `json:"test_to"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json")
return
}
host := req.Host
port := req.Port
user := req.User
pass := req.Pass
security := req.Security
from := req.From
to := req.To
if to == "" {
to = from
}
if host == "" || port == "" || from == "" {
jsonOK(w, map[string]interface{}{"ok": false, "error": "host, port and from required"})
return
}
if err := s.smtpTest(host, port, user, pass, security, from, to); err != nil {
jsonOK(w, map[string]interface{}{"ok": false, "error": err.Error()})
return
}
jsonOK(w, map[string]interface{}{"ok": true})
}
func (s *Server) handleAdminAPI(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
path := strings.TrimPrefix(r.URL.Path, "/admin")
switch {
case path == "/api/keys" && r.Method == "GET":
rows, err := s.db.Query("SELECT id, name, api_key FROM server_devices ORDER BY created_at")
if err != nil {
jsonErr(w, 500, err.Error())
return
}
defer rows.Close()
var out []map[string]string
for rows.Next() {
var id, name, key string
rows.Scan(&id, &name, &key)
out = append(out, map[string]string{"id": id, "name": name, "api_key": key})
}
jsonOK(w, out)
case path == "/api/keys" && r.Method == "POST":
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
return
}
name := r.FormValue("name")
if name == "" {
jsonErr(w, 400, "name required")
return
}
b := make([]byte, 20)
rand.Read(b)
apiKey := hex.EncodeToString(b)
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
"INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)",
apiKey[:12], name, apiKey, now, now,
)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
case strings.HasPrefix(path, "/api/keys/") && r.Method == "DELETE":
id := strings.TrimPrefix(path, "/api/keys/")
_, err := s.db.Exec("DELETE FROM server_devices WHERE id=?", id)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
s.db.Exec("DELETE FROM server_user_devices WHERE device_id=?", id)
jsonOK(w, map[string]string{"status": "deleted"})
case path == "/api/smtp" && r.Method == "POST":
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
return
}
for _, key := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_security", "smtp_from", "server_url"} {
val := r.FormValue(key)
if val != "" {
s.smtpSet(key, val)
}
}
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")
}
}
// ============================================================
// Embedded admin login HTML
// ============================================================
const userRegisterHTML = `<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync — Регистрация</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
h1{font-size:20px;margin:0 0 20px;text-align:center}
p{text-align:center;font-size:12px;color:#666;margin-top:16px}
a{color:#6366f1}
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
input{width:100%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
button{width:100%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
button:hover{background:#4f46e5}
.hint{font-size:11px;color:#666;margin-top:-12px;margin-bottom:16px;text-align:center}
</style>
</head><body>
<form method="POST">
<h1>Регистрация</h1>
<label>Логин</label>
<input type="text" name="username" autofocus required>
<label>Email</label>
<input type="email" name="email" required>
<label>Пароль</label>
<input type="password" name="password" required minlength="8">
<div class="hint">Минимум 8 символов: латинские буквы + цифры</div>
<button>Зарегистрироваться</button>
<p>Уже есть аккаунт? <a href="/login">Войти</a></p>
</form>
</body></html>`
const userLoginHTML = `<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync — Вход</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
h1{font-size:20px;margin:0 0 20px;text-align:center}
p{text-align:center;font-size:12px;color:#666;margin-top:16px}
a{color:#6366f1}
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
input{width:100%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
button{width:100%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
button:hover{background:#4f46e5}</style>
</head><body>
<form method="POST">
<h1>Verstak Sync</h1>
<label>Логин или Email</label>
<input type="text" name="username" autofocus required>
<label>Пароль</label>
<input type="password" name="password" required>
<button>Войти</button>
<p><a href="/register">Зарегистрироваться</a> · <a href="/admin/login">Администратор?</a></p>
</form>
</body></html>`
const adminLoginHTML = `<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync — Admin Login</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
h1{font-size:20px;margin:0 0 20px;text-align:center}
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
input{width:100%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
button{width:100%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
button:hover{background:#4f46e5}</style>
</head><body>
<form method="POST">
<h1>Verstak Sync</h1>
<label>Логин</label>
<input type="text" name="username" autofocus required>
<label>Пароль</label>
<input type="password" name="password" required>
<button>Войти</button>
</form>
</body></html>`
const adminUsersHTML = `<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync — Пользователи</title>
<style>
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:960px;margin:0 auto}
a{color:#6366f1}
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
table{width:100%;border-collapse:collapse;margin-top:12px}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
th{font-size:12px;color:#888;text-transform:uppercase;cursor:pointer;user-select:none}
th:hover{color:#b0b0c0}
th.sorted{color:#6366f1}
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
.btn:hover{background:#222233}
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
.btn-primary:hover{background:#4f46e5}
.btn-danger{color:#ff6b6b;border-color:#4a2222}
.btn-danger:hover{background:#3a2222}
.btn-sm{padding:2px 8px;font-size:11px}
input{font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;box-sizing:border-box}
input:focus{outline:none;border-color:#6366f1}
.toolbar{display:flex;gap:8px;margin:12px 0;flex-wrap:wrap;align-items:center}
.pagination{display:flex;gap:8px;margin-top:12px;align-items:center;justify-content:center}
.pagination span{padding:4px 8px;font-size:12px;color:#888}
.badge{padding:2px 8px;border-radius:4px;font-size:11px}
.badge-green{background:#064e3b;color:#34d399}
.badge-red{background:#4a2222;color:#ff6b6b}
.badge-yellow{background:#4a3e00;color:#fbbf24}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100}
.modal{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:24px;width:400px;max-width:90vw;position:relative}
.modal h2{margin-top:0;font-size:16px}
.modal-close{position:absolute;top:10px;right:14px;font-size:20px;cursor:pointer;background:none;border:none;color:#888}
.modal-close:hover{color:#e4e4ef}
.form-row{display:flex;gap:8px;margin-bottom:12px;align-items:center}
.form-row label{font-size:12px;color:#888;min-width:80px;flex-shrink:0}
.form-row input{flex:1}
</style>
</head><body>
<h1>Пользователи</h1>
<p><a href="/admin/dashboard">← Дашборд</a></p>
<div class="toolbar">
<input id="filter-input" placeholder="Фильтр по логину..." style="width:200px" onkeyup="loadUsers()">
</div>
<table>
<thead><tr>
<th onclick="sortBy('username')">Логин <span id="s-username"></span></th>
<th onclick="sortBy('email')">Email <span id="s-email"></span></th>
<th onclick="sortBy('confirmed')">Статус <span id="s-confirmed"></span></th>
<th onclick="sortBy('devices')">Устройств <span id="s-devices"></span></th>
<th onclick="sortBy('last_seen')">Активность <span id="s-last_seen"></span></th>
<th>Действия</th>
</tr></thead>
<tbody id="users-tbody"></tbody>
</table>
<div class="pagination" id="pagination"></div>
<div id="confirm-modal" class="modal-overlay" style="display:none">
<div class="modal">
<button class="modal-close" onclick="closeConfirm()">&times;</button>
<h2 id="confirm-title">Подтверждение</h2>
<p id="confirm-text"></p>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn" onclick="closeConfirm()">Отмена</button>
<button class="btn btn-danger" id="confirm-btn" onclick="confirmAction()">Да</button>
</div>
</div>
</div>
<div id="edit-modal" class="modal-overlay" style="display:none">
<div class="modal">
<button class="modal-close" onclick="closeEdit()">&times;</button>
<h2>Редактировать пользователя</h2>
<div class="form-row"><label>Логин</label><input id="edit-username"></div>
<div class="form-row"><label>Email</label><input id="edit-email" type="email"></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn" onclick="closeEdit()">Отмена</button>
<button class="btn btn-primary" onclick="saveEdit()">Сохранить</button>
</div>
</div>
</div>
<div id="result-modal" class="modal-overlay" style="display:none">
<div class="modal" style="width:320px">
<button class="modal-close" onclick="closeResult()">&times;</button>
<h2 id="result-title">Результат</h2>
<p id="result-text" style="white-space:pre-wrap"></p>
<button class="btn btn-primary" onclick="closeResult()" style="margin-top:8px">OK</button>
</div>
</div>
<script>
var currentPage=1,currentSort='',currentOrder='',editUserId='',pendingAction=''
function loadUsers(){
var f=document.getElementById('filter-input').value
var u='/admin/api/users?page='+currentPage+'&per_page=20&filter='+encodeURIComponent(f)
if(currentSort){u+='&sort='+currentSort+'&order='+currentOrder}
fetch(u).then(function(r){return r.json()}).then(function(d){
var tbody=document.getElementById('users-tbody')
tbody.innerHTML=''
d.users.forEach(function(u){
var status=u.confirmed?'<span class="badge badge-green">Подтверждён</span>':'<span class="badge badge-yellow">Не подтверждён</span>'
if(u.blocked){status='<span class="badge badge-red">Заблокирован</span>'}
var lastSeen=u.last_seen?new Date(u.last_seen).toLocaleString():'-'
var blockText=u.blocked?'Разблокировать':'Заблокировать'
var tr=document.createElement('tr')
tr.innerHTML='<td>'+esc(u.username)+'</td><td>'+esc(u.email)+'</td><td>'+status+'</td><td>'+u.devices+'</td><td>'+lastSeen+'</td>'+
'<td><button class="btn btn-sm" onclick="editUser(\''+u.id+'\',\''+escJS(u.username)+'\',\''+escJS(u.email)+'\')">✎</button> '+
'<button class="btn btn-sm" onclick="askBlock(\''+u.id+'\','+u.blocked+')">'+blockText+'</button> '+
'<button class="btn btn-sm" onclick="askReset(\''+u.id+'\')">Сброс пароля</button> '+
'<button class="btn btn-sm btn-danger" onclick="askDelete(\''+u.id+'\',\''+escJS(u.username)+'\')">✕</button></td>'
tbody.appendChild(tr)
})
if(!d.users.length){tbody.innerHTML='<tr><td colspan="6" style="text-align:center;color:#666">Нет пользователей</td></tr>'}
var totalPages=Math.ceil(d.total/d.per_page)
var pag=document.getElementById('pagination')
pag.innerHTML=''
if(totalPages>1){
var prev=document.createElement('button')
prev.className='btn btn-sm';prev.textContent='←';prev.onclick=function(){if(currentPage>1){currentPage--;loadUsers()}}
pag.appendChild(prev)
var s=document.createElement('span')
s.textContent=d.page+' / '+totalPages
pag.appendChild(s)
var next=document.createElement('button')
next.className='btn btn-sm';next.textContent='→';next.onclick=function(){if(currentPage<totalPages){currentPage++;loadUsers()}}
pag.appendChild(next)
}
})
}
function sortBy(col){
if(currentSort===col){currentOrder=currentOrder==='asc'?'desc':'asc'}
else{currentSort=col;currentOrder='asc'}
document.querySelectorAll('th').forEach(function(th){th.classList.remove('sorted')})
var el=document.getElementById('s-'+col)
if(el){el.parentElement.classList.add('sorted');el.textContent=currentOrder==='asc'?' ▲':' ▼'}
loadUsers()
}
function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
function escJS(s){return s.replace(/'/g,"\\'").replace(/"/g,'&quot;')}
function editUser(id,username,email){
editUserId=id;document.getElementById('edit-username').value=username;document.getElementById('edit-email').value=email;document.getElementById('edit-modal').style.display='flex'}
function closeEdit(){document.getElementById('edit-modal').style.display='none'}
function saveEdit(){
var un=document.getElementById('edit-username').value,em=document.getElementById('edit-email').value
if(!un||!em)return
fetch('/admin/api/users/'+editUserId+'/edit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:un,email:em})}).then(function(r){return r.json()}).then(function(d){closeEdit();if(d.status==='ok')loadUsers()})
}
function askBlock(id,blocked){
pendingAction=function(){fetch('/admin/api/users/'+id+'/block',{method:'POST'}).then(function(r){return r.json()}).then(function(d){loadUsers()})}
document.getElementById('confirm-title').textContent=blocked?'Разблокировать пользователя?':'Заблокировать пользователя?'
document.getElementById('confirm-text').textContent=blocked?'Пользователь сможет снова войти.':'Пользователь не сможет войти.'
document.getElementById('confirm-btn').textContent=blocked?'Разблокировать':'Заблокировать'
document.getElementById('confirm-modal').style.display='flex'}
function askReset(id){
pendingAction=function(){
fetch('/admin/api/users/'+id+'/reset-password',{method:'POST'}).then(function(r){return r.json()}).then(function(d){
document.getElementById('confirm-modal').style.display='none'
document.getElementById('result-title').textContent='Новый пароль'
document.getElementById('result-text').textContent='Новый пароль: '+d.new_password+'\nСообщите его пользователю.'
document.getElementById('result-modal').style.display='flex'})}
document.getElementById('confirm-title').textContent='Сбросить пароль?'
document.getElementById('confirm-text').textContent='Пользователь не сможет войти со старым паролем.'
document.getElementById('confirm-btn').textContent='Сбросить'
document.getElementById('confirm-modal').style.display='flex'}
function askDelete(id,username){
pendingAction=function(){fetch('/admin/api/users/'+id,{method:'DELETE'}).then(function(r){return r.json()}).then(function(d){loadUsers()})}
document.getElementById('confirm-title').textContent='Удалить пользователя?'
document.getElementById('confirm-text').textContent='Будет удалён пользователь "'+username+'" и все его устройства.'
document.getElementById('confirm-btn').textContent='Удалить'
document.getElementById('confirm-modal').style.display='flex'}
function closeConfirm(){document.getElementById('confirm-modal').style.display='none';pendingAction=''}
function confirmAction(){if(pendingAction){pendingAction();pendingAction=''}}
function closeResult(){document.getElementById('result-modal').style.display='none'}
loadUsers()
</script>
</body></html>`
const confirmedHTML = `<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync — Email подтверждён</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px;text-align:center}
h1{font-size:20px;margin:0 0 12px;color:#34d399}
p{font-size:13px;color:#b0b0c0;margin:0 0 20px}
a{color:#6366f1;text-decoration:none}
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none}
.btn:hover{background:#4f46e5}</style>
</head><body>
<div class="box">
<h1>✓ Email подтверждён</h1>
<p>Ваш email успешно подтверждён. Теперь вы можете войти в систему.</p>
<a href="/login" class="btn">Войти</a>
</div>
</body></html>`
const registrationOKHTML = `<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync — Регистрация</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
h1{font-size:20px;margin:0 0 12px;color:#34d399}
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
a{color:#6366f1;text-decoration:none}
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
.btn:hover{background:#4f46e5}</style>
</head><body>
<div class="box">
<h1>✓ Регистрация успешна</h1>
<p>На вашу почту отправлено письмо с подтверждением.</p>
<p>Перейдите по ссылке в письме, чтобы активировать аккаунт.</p>
<a href="/login" class="btn">Войти</a>
</div>
</body></html>`
const registrationAutoHTML = `<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync — Регистрация</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
h1{font-size:20px;margin:0 0 12px;color:#34d399}
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
a{color:#6366f1;text-decoration:none}
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
.btn:hover{background:#4f46e5}</style>
</head><body>
<div class="box">
<h1>✓ Регистрация успешна</h1>
<p>Вы можете войти — подтверждение email не требуется.</p>
<a href="/login" class="btn">Войти</a>
</div>
</body></html>`