2839 lines
100 KiB
Go
2839 lines
100 KiB
Go
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,
|
||
token_hash TEXT,
|
||
token_prefix TEXT,
|
||
token_suffix TEXT,
|
||
user_id TEXT,
|
||
client_version TEXT,
|
||
last_ip TEXT,
|
||
last_seen TEXT,
|
||
revoked_at 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,
|
||
server_sequence INTEGER,
|
||
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,
|
||
idempotency_key TEXT,
|
||
client_sequence INTEGER DEFAULT 0,
|
||
last_seen_server_seq INTEGER DEFAULT 0,
|
||
created_at TEXT NOT NULL,
|
||
pushed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS server_tombstones (
|
||
entity_type TEXT NOT NULL,
|
||
entity_id TEXT NOT NULL,
|
||
op_id TEXT NOT NULL,
|
||
deleted_at TEXT NOT NULL,
|
||
PRIMARY KEY (entity_type, entity_id)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS server_idempotency_keys (
|
||
idempotency_key TEXT PRIMARY KEY,
|
||
response_json TEXT NOT NULL,
|
||
created_at TEXT NOT NULL
|
||
);
|
||
|
||
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)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS server_audit_log (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
event_type TEXT NOT NULL,
|
||
user_id TEXT,
|
||
device_id TEXT,
|
||
ip TEXT,
|
||
message TEXT,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
`
|
||
|
||
// ============================================================
|
||
// Server
|
||
// ============================================================
|
||
|
||
type pairRateLimit struct {
|
||
mu sync.Mutex
|
||
attempts map[string]int
|
||
}
|
||
|
||
func (p *pairRateLimit) allow(ip string) bool {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
if p.attempts == nil {
|
||
p.attempts = make(map[string]int)
|
||
}
|
||
p.attempts[ip]++
|
||
return p.attempts[ip] <= 5
|
||
}
|
||
|
||
func (p *pairRateLimit) reset(ip string) {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
delete(p.attempts, ip)
|
||
}
|
||
|
||
type Server struct {
|
||
db *sql.DB
|
||
cfg *Config
|
||
tokens *tokenStore
|
||
userTokens *userTokenStore
|
||
blobsDir string
|
||
mux *http.ServeMux
|
||
pairLimit *pairRateLimit
|
||
}
|
||
|
||
func (s *Server) auditLog(eventType, userID, deviceID, ip, msg string) {
|
||
s.db.Exec("INSERT INTO server_audit_log (event_type, user_id, device_id, ip, message, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||
eventType, userID, deviceID, ip, msg, time.Now().UTC().Format(time.RFC3339))
|
||
}
|
||
|
||
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")
|
||
db.Exec("ALTER TABLE server_devices ADD COLUMN token_hash TEXT")
|
||
db.Exec("ALTER TABLE server_devices ADD COLUMN token_prefix TEXT")
|
||
db.Exec("ALTER TABLE server_devices ADD COLUMN token_suffix TEXT")
|
||
db.Exec("ALTER TABLE server_devices ADD COLUMN user_id TEXT")
|
||
db.Exec("ALTER TABLE server_devices ADD COLUMN client_version TEXT")
|
||
db.Exec("ALTER TABLE server_devices ADD COLUMN last_ip TEXT")
|
||
db.Exec("ALTER TABLE server_devices ADD COLUMN revoked_at TEXT")
|
||
|
||
// Migration: add server_sequence and tombstones.
|
||
db.Exec("ALTER TABLE server_ops ADD COLUMN server_sequence INTEGER")
|
||
db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_sequence ON server_ops(server_sequence)")
|
||
db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_entity ON server_ops(entity_type, entity_id)")
|
||
db.Exec(`CREATE TABLE IF NOT EXISTS server_tombstones (
|
||
entity_type TEXT NOT NULL,
|
||
entity_id TEXT NOT NULL,
|
||
op_id TEXT NOT NULL,
|
||
deleted_at TEXT NOT NULL,
|
||
PRIMARY KEY (entity_type, entity_id)
|
||
)`)
|
||
db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_sequence ON server_ops(server_sequence)")
|
||
db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_entity ON server_ops(entity_type, entity_id)")
|
||
db.Exec(`CREATE TABLE IF NOT EXISTS server_idempotency_keys (
|
||
idempotency_key TEXT PRIMARY KEY,
|
||
response_json TEXT NOT NULL,
|
||
created_at TEXT NOT NULL
|
||
)`)
|
||
db.Exec(`ALTER TABLE server_ops ADD COLUMN idempotency_key TEXT`)
|
||
db.Exec(`ALTER TABLE server_ops ADD COLUMN client_sequence INTEGER DEFAULT 0`)
|
||
db.Exec(`ALTER TABLE server_ops ADD COLUMN last_seen_server_seq INTEGER DEFAULT 0`)
|
||
|
||
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,
|
||
pairLimit: &pairRateLimit{},
|
||
}
|
||
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/client/pair", s.handleClientPair)
|
||
mux.HandleFunc("/api/auth/test", s.handleAuthTest)
|
||
mux.HandleFunc("/api/client/revoke-current", s.handleClientRevoke)
|
||
mux.HandleFunc("/api/client/me", s.handleClientMe)
|
||
mux.HandleFunc("/api/client/revoke-device", s.handleClientRevokeDevice)
|
||
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("/forgot", s.handleUserWebForgot)
|
||
mux.HandleFunc("/reset", s.handleUserWebReset)
|
||
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
|
||
}
|
||
// First try device token (hashed).
|
||
hash := sha256Hex(key)
|
||
var deviceID, userID, revokedAt sql.NullString
|
||
err := s.db.QueryRow("SELECT id, user_id, revoked_at FROM server_devices WHERE token_hash=?", hash).Scan(&deviceID, &userID, &revokedAt)
|
||
if err == nil {
|
||
if revokedAt.Valid && revokedAt.String != "" {
|
||
jsonErr(w, 401, "device revoked")
|
||
return false
|
||
}
|
||
// Check user not blocked.
|
||
var blocked int
|
||
if userID.Valid && userID.String != "" {
|
||
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", userID.String).Scan(&blocked)
|
||
if blocked != 0 {
|
||
jsonErr(w, 403, "user blocked")
|
||
return false
|
||
}
|
||
}
|
||
r.Header.Set("X-Device-ID", deviceID.String)
|
||
r.Header.Set("X-User-ID", userID.String)
|
||
s.db.Exec("UPDATE server_devices SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), deviceID.String)
|
||
return true
|
||
}
|
||
// Fallback to plain api_key (legacy).
|
||
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 errorPageHTML(title, msg, backURL string) string {
|
||
return 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;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;text-align:center;max-width:360px}
|
||
h1{font-size:18px;margin:0 0 12px;color:#ff6b6b}
|
||
p{font-size:13px;color:#b0b0c0;margin:0 0 16px}
|
||
a{color:#6366f1;text-decoration:none}
|
||
a:hover{text-decoration:underline}</style>
|
||
</head><body>
|
||
<div class="box">
|
||
<h1>%s</h1>
|
||
<p>%s</p>
|
||
<a href="%s">← Назад</a>
|
||
</div>
|
||
</body></html>`, title, title, msg, backURL)
|
||
}
|
||
|
||
func sha256Hex(s string) string {
|
||
h := sha256.Sum256([]byte(s))
|
||
return hex.EncodeToString(h[:])
|
||
}
|
||
|
||
func genDeviceToken() (token, prefix, suffix string) {
|
||
b := make([]byte, 32)
|
||
rand.Read(b)
|
||
token = "vs_dev_" + hex.EncodeToString(b)
|
||
prefix = token[:16]
|
||
suffix = token[len(token)-8:]
|
||
return
|
||
}
|
||
|
||
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) handleClientPair(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "POST" {
|
||
jsonErr(w, 405, "POST required")
|
||
return
|
||
}
|
||
ip := r.RemoteAddr
|
||
if idx := strings.LastIndex(ip, ":"); idx >= 0 {
|
||
ip = ip[:idx]
|
||
}
|
||
if !s.pairLimit.allow(ip) {
|
||
s.auditLog("rate_limit_exceeded", "", "", ip, "pair rate limit exceeded")
|
||
jsonErr(w, 429, "too many attempts")
|
||
return
|
||
}
|
||
var req struct {
|
||
Login string `json:"login"`
|
||
Password string `json:"password"`
|
||
DeviceName string `json:"device_name"`
|
||
ClientVersion string `json:"client_version"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
jsonErr(w, 400, "bad json")
|
||
return
|
||
}
|
||
if req.Login == "" || req.Password == "" {
|
||
jsonErr(w, 400, "login and password required")
|
||
return
|
||
}
|
||
if req.DeviceName == "" {
|
||
req.DeviceName = "unknown"
|
||
}
|
||
// Look up user.
|
||
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.Login, strings.ToLower(req.Login)).Scan(&userID, &hash, &confirmed, &blocked)
|
||
if err != nil {
|
||
s.auditLog("device_auth_failed", "", "", ip, "pair: user not found: "+req.Login)
|
||
jsonErr(w, 401, "invalid credentials")
|
||
return
|
||
}
|
||
if blocked != 0 {
|
||
s.auditLog("device_auth_failed", userID, "", ip, "pair: user blocked")
|
||
jsonErr(w, 403, "account blocked")
|
||
return
|
||
}
|
||
if confirmed == 0 {
|
||
s.auditLog("device_auth_failed", userID, "", ip, "pair: email not confirmed")
|
||
jsonErr(w, 403, "email not confirmed")
|
||
return
|
||
}
|
||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
||
s.auditLog("device_auth_failed", userID, "", ip, "pair: wrong password")
|
||
jsonErr(w, 401, "invalid credentials")
|
||
return
|
||
}
|
||
// Generate device.
|
||
devID := make([]byte, 12)
|
||
rand.Read(devID)
|
||
deviceID := "dev_" + hex.EncodeToString(devID)
|
||
token, prefix, suffix := genDeviceToken()
|
||
tokenHash := sha256Hex(token)
|
||
now := time.Now().UTC().Format(time.RFC3339)
|
||
apiKey := make([]byte, 20)
|
||
rand.Read(apiKey)
|
||
_, err = s.db.Exec(`INSERT INTO server_devices
|
||
(id, name, api_key, token_hash, token_prefix, token_suffix, user_id, client_version, last_ip, last_seen, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
deviceID, req.DeviceName, hex.EncodeToString(apiKey), tokenHash, prefix, suffix,
|
||
userID, req.ClientVersion, ip, now, now)
|
||
if err != nil {
|
||
jsonErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
|
||
s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", now, userID)
|
||
s.pairLimit.reset(ip)
|
||
s.auditLog("device_paired", userID, deviceID, ip, "device paired: "+req.DeviceName)
|
||
jsonOK(w, map[string]interface{}{
|
||
"user_id": userID,
|
||
"device_id": deviceID,
|
||
"device_token": token,
|
||
"server_time": now,
|
||
"initial_sync_cursor": 0,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleAuthTest(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, "bad json")
|
||
return
|
||
}
|
||
if req.Username == "" || req.Password == "" {
|
||
jsonErr(w, 400, "username and password required")
|
||
return
|
||
}
|
||
var hash string
|
||
var confirmed, blocked int
|
||
err := s.db.QueryRow("SELECT password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
||
req.Username, strings.ToLower(req.Username)).Scan(&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
|
||
}
|
||
jsonOK(w, map[string]string{"status": "ok"})
|
||
}
|
||
|
||
func (s *Server) handleClientRevoke(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "POST" {
|
||
jsonErr(w, 405, "POST required")
|
||
return
|
||
}
|
||
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||
if tok == "" {
|
||
jsonErr(w, 401, "token required")
|
||
return
|
||
}
|
||
hash := sha256Hex(tok)
|
||
var deviceID, userID string
|
||
err := s.db.QueryRow("SELECT id, user_id FROM server_devices WHERE token_hash=?", hash).Scan(&deviceID, &userID)
|
||
if err != nil {
|
||
jsonErr(w, 401, "invalid token")
|
||
return
|
||
}
|
||
now := time.Now().UTC().Format(time.RFC3339)
|
||
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, deviceID)
|
||
s.auditLog("device_revoked", userID, deviceID, r.RemoteAddr, "device revoked by user")
|
||
jsonOK(w, map[string]string{"status": "revoked"})
|
||
}
|
||
|
||
func (s *Server) handleClientRevokeDevice(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "POST" {
|
||
jsonErr(w, 405, "POST required")
|
||
return
|
||
}
|
||
userID, ok := s.requireUserWeb(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
var req struct {
|
||
DeviceID string `json:"device_id"`
|
||
Password string `json:"password"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
jsonErr(w, 400, "invalid JSON")
|
||
return
|
||
}
|
||
if req.DeviceID == "" || req.Password == "" {
|
||
jsonErr(w, 400, "device_id and password required")
|
||
return
|
||
}
|
||
// Verify password.
|
||
var pwHash string
|
||
err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", userID).Scan(&pwHash)
|
||
if err != nil {
|
||
jsonErr(w, 403, "access denied")
|
||
return
|
||
}
|
||
if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(req.Password)) != nil {
|
||
jsonErr(w, 403, "wrong password")
|
||
return
|
||
}
|
||
// Verify device belongs to user.
|
||
var devUserID string
|
||
err = s.db.QueryRow("SELECT user_id FROM server_devices WHERE id=?", req.DeviceID).Scan(&devUserID)
|
||
if err != nil {
|
||
jsonErr(w, 404, "device not found")
|
||
return
|
||
}
|
||
if devUserID != userID {
|
||
jsonErr(w, 403, "device does not belong to you")
|
||
return
|
||
}
|
||
now := time.Now().UTC().Format(time.RFC3339)
|
||
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, req.DeviceID)
|
||
s.auditLog("device_revoked", userID, req.DeviceID, r.RemoteAddr, "device revoked via web dashboard")
|
||
jsonOK(w, map[string]string{"status": "revoked"})
|
||
}
|
||
|
||
func (s *Server) handleClientMe(w http.ResponseWriter, r *http.Request) {
|
||
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||
if tok == "" {
|
||
jsonErr(w, 401, "token required")
|
||
return
|
||
}
|
||
hash := sha256Hex(tok)
|
||
var deviceID, userID, name, clientVer, lastSeen, revokedAt, createdAt string
|
||
err := s.db.QueryRow(`SELECT d.id, d.user_id, d.name, COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at
|
||
FROM server_devices d WHERE d.token_hash=?`, hash).
|
||
Scan(&deviceID, &userID, &name, &clientVer, &lastSeen, &revokedAt, &createdAt)
|
||
if err != nil {
|
||
jsonErr(w, 401, "invalid token")
|
||
return
|
||
}
|
||
var username string
|
||
s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
|
||
jsonOK(w, map[string]interface{}{
|
||
"device_id": deviceID,
|
||
"user_id": userID,
|
||
"username": username,
|
||
"device_name": name,
|
||
"client_version": clientVer,
|
||
"last_seen": lastSeen,
|
||
"revoked_at": revokedAt,
|
||
"created_at": createdAt,
|
||
})
|
||
}
|
||
|
||
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 != "" {
|
||
srvURL := s.smtpGet("server_url")
|
||
resetURL := fmt.Sprintf("/api/v1/auth/reset?token=%s", tokenStr)
|
||
if srvURL != "" {
|
||
resetURL = fmt.Sprintf("%s/api/v1/auth/reset?token=%s", srvURL, 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"`
|
||
IdempotencyKey string `json:"idempotency_key"`
|
||
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"`
|
||
ClientSequence int `json:"client_sequence"`
|
||
LastSeenServerSeq int `json:"last_seen_server_seq"`
|
||
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
|
||
}
|
||
|
||
// Idempotency: if request-level key provided, check for cached response.
|
||
if req.IdempotencyKey != "" {
|
||
var cachedJSON string
|
||
err := s.db.QueryRow("SELECT response_json FROM server_idempotency_keys WHERE idempotency_key=?", req.IdempotencyKey).Scan(&cachedJSON)
|
||
if err == nil {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.Write([]byte(cachedJSON))
|
||
return
|
||
}
|
||
}
|
||
|
||
now := time.Now().UTC().Format(time.RFC3339)
|
||
var accepted []string
|
||
var conflicts []map[string]interface{}
|
||
|
||
for _, op := range req.Ops {
|
||
if op.OpID == "" || op.EntityType == "" || op.EntityID == "" || op.OpType == "" {
|
||
continue
|
||
}
|
||
// Conflict detection: check if another device already created ops for this entity
|
||
// with a server_sequence higher than what this client last saw.
|
||
if op.LastSeenServerSeq > 0 {
|
||
conflictRows, err := s.db.Query(`
|
||
SELECT op_id, device_id, op_type, server_sequence FROM server_ops
|
||
WHERE entity_type=? AND entity_id=? AND device_id!=?
|
||
AND server_sequence > ? AND op_type != 'delete'
|
||
ORDER BY server_sequence`, op.EntityType, op.EntityID, req.DeviceID, op.LastSeenServerSeq)
|
||
if err == nil {
|
||
for conflictRows.Next() {
|
||
var cOpID, cDevID, cOpType string
|
||
var cSeq int
|
||
conflictRows.Scan(&cOpID, &cDevID, &cOpType, &cSeq)
|
||
conflicts = append(conflicts, map[string]interface{}{
|
||
"op_id": cOpID,
|
||
"device_id": cDevID,
|
||
"op_type": cOpType,
|
||
"server_sequence": cSeq,
|
||
"entity_type": op.EntityType,
|
||
"entity_id": op.EntityID,
|
||
})
|
||
}
|
||
conflictRows.Close()
|
||
}
|
||
}
|
||
|
||
res, err := s.db.Exec(
|
||
`INSERT OR IGNORE INTO server_ops (op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, idempotency_key, client_sequence, last_seen_server_seq, created_at, pushed_at)
|
||
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
op.OpID, req.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON,
|
||
req.IdempotencyKey, op.ClientSequence, op.LastSeenServerSeq, op.CreatedAt, now,
|
||
)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
if n == 0 {
|
||
continue // duplicate op_id
|
||
}
|
||
seqRes, err := s.db.Exec("INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)", op.OpID, req.DeviceID)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
seq, _ := seqRes.LastInsertId()
|
||
s.db.Exec("UPDATE server_ops SET server_sequence=? WHERE op_id=?", seq, op.OpID)
|
||
|
||
if op.OpType == "delete" {
|
||
s.db.Exec(`INSERT OR REPLACE INTO server_tombstones (entity_type, entity_id, op_id, deleted_at) VALUES (?, ?, ?, ?)`,
|
||
op.EntityType, op.EntityID, op.OpID, now)
|
||
}
|
||
|
||
accepted = append(accepted, op.OpID)
|
||
}
|
||
|
||
resp := map[string]interface{}{
|
||
"accepted": accepted,
|
||
"count": len(accepted),
|
||
"conflicts": conflicts,
|
||
}
|
||
|
||
// Cache response for idempotency.
|
||
if req.IdempotencyKey != "" {
|
||
if respJSON, err := json.Marshal(resp); err == nil {
|
||
s.db.Exec("INSERT OR IGNORE INTO server_idempotency_keys (idempotency_key, response_json, created_at) VALUES (?, ?, ?)",
|
||
req.IdempotencyKey, string(respJSON), now)
|
||
}
|
||
}
|
||
|
||
jsonOK(w, resp)
|
||
}
|
||
|
||
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 {
|
||
SinceSequence int `json:"since_sequence"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
jsonErr(w, 400, "invalid JSON")
|
||
return
|
||
}
|
||
|
||
var serverSeq int
|
||
s.db.QueryRow("SELECT COALESCE(MAX(server_sequence), 0) FROM server_ops").Scan(&serverSeq)
|
||
|
||
rows, err := s.db.Query(`
|
||
SELECT op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, created_at
|
||
FROM server_ops
|
||
WHERE server_sequence > ? AND server_sequence IS NOT NULL
|
||
ORDER BY server_sequence`, req.SinceSequence)
|
||
if err != nil {
|
||
jsonErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
type opDTO struct {
|
||
OpID string `json:"op_id"`
|
||
ServerSequence int `json:"server_sequence"`
|
||
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.ServerSequence, &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_sequence": serverSeq,
|
||
"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) handleUserWebForgot(w http.ResponseWriter, r *http.Request) {
|
||
switch r.Method {
|
||
case "GET":
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
w.Write([]byte(forgotPasswordHTML))
|
||
case "POST":
|
||
if err := r.ParseForm(); err != nil {
|
||
jsonErr(w, 400, "bad form")
|
||
return
|
||
}
|
||
email := strings.ToLower(r.FormValue("email"))
|
||
if email == "" {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
w.Write([]byte(errorPageHTML("Ошибка", "Email обязателен", "/forgot")))
|
||
return
|
||
}
|
||
var userID string
|
||
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", email).Scan(&userID)
|
||
if err != nil {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
w.Write([]byte(forgotSentHTML))
|
||
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 != "" {
|
||
srvURL := s.smtpGet("server_url")
|
||
resetURL := fmt.Sprintf("/reset?token=%s", tokenStr)
|
||
if srvURL != "" {
|
||
resetURL = fmt.Sprintf("%s/reset?token=%s", srvURL, tokenStr)
|
||
}
|
||
body := fmt.Sprintf("Reset your Verstak Sync password:\n\n%s\n\nThis link expires in 1 hour.", resetURL)
|
||
if err := s.smtpSend(email, "Verstak Sync password reset", body); err != nil {
|
||
log.Printf("forgot web: failed to send reset email: %v", err)
|
||
}
|
||
} else {
|
||
log.Printf("forgot web: SMTP not configured, reset token=%s for email %s", tokenStr, email)
|
||
}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
w.Write([]byte(forgotSentHTML))
|
||
default:
|
||
jsonErr(w, 405, "method not allowed")
|
||
}
|
||
}
|
||
|
||
func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
|
||
switch r.Method {
|
||
case "GET":
|
||
token := r.URL.Query().Get("token")
|
||
if token == "" {
|
||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||
return
|
||
}
|
||
// Validate token exists and not expired before showing form.
|
||
var userID, expiresAt string
|
||
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
|
||
token).Scan(&userID, &expiresAt)
|
||
if err != nil {
|
||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||
return
|
||
}
|
||
exp, err := time.Parse(time.RFC3339, expiresAt)
|
||
if err != nil || time.Now().After(exp) {
|
||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
html := strings.ReplaceAll(resetPasswordHTML, "{TOKEN}", token)
|
||
w.Write([]byte(html))
|
||
case "POST":
|
||
if err := r.ParseForm(); err != nil {
|
||
jsonErr(w, 400, "bad form")
|
||
return
|
||
}
|
||
token := r.FormValue("token")
|
||
newPass := r.FormValue("password")
|
||
confirm := r.FormValue("confirm")
|
||
if token == "" || newPass == "" || confirm == "" {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
w.Write([]byte(errorPageHTML("Ошибка", "Все поля обязательны", "/forgot")))
|
||
return
|
||
}
|
||
if err := validatePassword(newPass); err != "" {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
w.Write([]byte(errorPageHTML("Ошибка", err, "/reset?token="+token)))
|
||
return
|
||
}
|
||
if newPass != confirm {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
w.Write([]byte(errorPageHTML("Ошибка", "Пароли не совпадают", "/reset?token="+token)))
|
||
return
|
||
}
|
||
var userID string
|
||
err := s.db.QueryRow("SELECT user_id FROM server_email_tokens WHERE token=? AND purpose='reset'", token).Scan(&userID)
|
||
if err != nil {
|
||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||
return
|
||
}
|
||
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
|
||
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
|
||
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", token)
|
||
log.Printf("reset: user %s reset password", userID)
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
w.Write([]byte(resetDoneHTML))
|
||
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
|
||
}
|
||
var username string
|
||
s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
|
||
|
||
// Get devices with status info.
|
||
type dev struct {
|
||
ID, Name, LastSeen, CreatedAt, ClientVer, RevokedAt string
|
||
}
|
||
var devices []dev
|
||
rows, err := s.db.Query(`
|
||
SELECT d.id, d.name, COALESCE(d.last_seen,''), d.created_at,
|
||
COALESCE(d.client_version,''), COALESCE(d.revoked_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 DESC`, userID)
|
||
if err == nil {
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var d dev
|
||
rows.Scan(&d.ID, &d.Name, &d.LastSeen, &d.CreatedAt, &d.ClientVer, &d.RevokedAt)
|
||
devices = append(devices, d)
|
||
}
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
deviceRows := ""
|
||
if len(devices) == 0 {
|
||
deviceRows = "<tr><td colspan='5' style='color:#666;text-align:center;padding:24px'>Нет подключённых устройств.<br>Подключите устройство из desktop-клиента Verstak.</td></tr>"
|
||
} else {
|
||
for _, d := range devices {
|
||
ls := d.LastSeen
|
||
if ls == "" {
|
||
ls = "—"
|
||
}
|
||
created := d.CreatedAt
|
||
if len(created) > 10 {
|
||
created = created[:10]
|
||
}
|
||
status := "<span style='color:#34d399'>Активно</span>"
|
||
revokeBtn := fmt.Sprintf(`<button class="btn btn-danger btn-sm" onclick="revokeDevice('%s')">Отозвать</button>`, d.ID)
|
||
if d.RevokedAt != "" {
|
||
status = "<span style='color:#ff6b6b'>Отозвано</span>"
|
||
revokeBtn = ""
|
||
}
|
||
deviceRows += fmt.Sprintf(`<tr>
|
||
<td>%s</td>
|
||
<td>%s</td>
|
||
<td>%s</td>
|
||
<td>%s</td>
|
||
<td>%s %s</td>
|
||
</tr>`, d.Name, status, created, ls, d.ClientVer, revokeBtn)
|
||
}
|
||
}
|
||
|
||
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}
|
||
.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}
|
||
.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>Статус</th><th>Подключено</th><th>Активность</th><th>Версия</th></tr>%s</table>
|
||
|
||
<div style="margin-top:24px;padding:16px;background:#1a1a28;border:1px solid #2a2a3c;border-radius:8px">
|
||
<h2 style="margin-top:0">Подключить новое устройство</h2>
|
||
<p style="font-size:13px;color:#888">Откройте desktop-клиент Verstak, перейдите в настройки синхронизации и введите URL сервера, логин и пароль.</p>
|
||
</div>
|
||
|
||
<script>
|
||
function revokeDevice(id){
|
||
if(!confirm('Отозвать устройство? Оно перестанет синхронизироваться.'))return
|
||
var pw=prompt('Введите ваш пароль для подтверждения:')
|
||
if(!pw)return
|
||
fetch('/api/client/revoke-device',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({device_id:id,password:pw})}).then(function(r){return r.json()}).then(function(d){
|
||
if(d.status==='revoked'){location.reload()}else{alert(d.error||'error')}
|
||
})
|
||
}
|
||
</script>
|
||
</body></html>`, username, username, deviceRows)
|
||
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 := `<!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>Устройства</h2>
|
||
<div id="devices"></div>
|
||
<script>
|
||
fetch('/admin/api/devices').then(r=>r.json()).then(devices=>{
|
||
const div=document.getElementById('devices')
|
||
if(!devices.length){div.innerHTML='<p>Нет устройств</p>';return}
|
||
div.innerHTML='<table><tr><th>Устройство</th><th>Пользователь</th><th>Версия</th><th>Статус</th><th>Активность</th><th></th></tr>'+
|
||
devices.map(d=>{
|
||
var status=d.revoked_at?'<span style="color:#ff6b6b">Отозвано</span>':'<span style="color:#34d399">Активно</span>'
|
||
var ls=d.last_seen||'—'
|
||
var revBtn=''
|
||
if(!d.revoked_at) revBtn='<button class="btn btn-danger" onclick="revokeDevice(\''+d.id+'\')">Отозвать</button>'
|
||
return '<tr><td>'+d.name+'</td><td>'+(d.user||'—')+'</td><td>'+(d.client_version||'—')+'</td><td>'+status+'</td><td>'+ls+'</td><td>'+revBtn+'</td></tr>'
|
||
}).join('')+'</table>'
|
||
document.getElementById('dev-count').textContent=devices.length
|
||
})
|
||
fetch('/admin/api/stats').then(r=>r.json()).then(stats=>{
|
||
document.getElementById('op-count').textContent=stats.ops||'0'
|
||
})
|
||
function revokeDevice(id){
|
||
if(!confirm('Отозвать устройство?'))return
|
||
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>
|
||
|
||
<div id="smtp-modal" class="modal-overlay" style="display:none" onclick="closeSMTP(event)">
|
||
<div class="modal">
|
||
<button class="modal-close" onclick="closeSMTP()">×</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()">×</button>
|
||
<h2>Health check</h2>
|
||
<pre id="health-result">Загрузка...</pre>
|
||
</div>
|
||
</div>
|
||
_ = smtpURL
|
||
_ = smtpUser
|
||
_ = smtpFrom
|
||
_ = smtpSecurity
|
||
_ = smtpHost
|
||
_ = smtpPort
|
||
|
||
</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/devices" && r.Method == "GET":
|
||
rows, err := s.db.Query(`
|
||
SELECT d.id, d.name, d.client_version, COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at,
|
||
COALESCE(u.username,'')
|
||
FROM server_devices d
|
||
LEFT JOIN server_users u ON u.id = d.user_id
|
||
ORDER BY d.created_at DESC`)
|
||
if err != nil {
|
||
jsonErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
type devDTO struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
ClientVersion string `json:"client_version"`
|
||
LastSeen string `json:"last_seen"`
|
||
RevokedAt string `json:"revoked_at"`
|
||
CreatedAt string `json:"created_at"`
|
||
User string `json:"user"`
|
||
}
|
||
var out []devDTO
|
||
for rows.Next() {
|
||
var d devDTO
|
||
rows.Scan(&d.ID, &d.Name, &d.ClientVersion, &d.LastSeen, &d.RevokedAt, &d.CreatedAt, &d.User)
|
||
out = append(out, d)
|
||
}
|
||
jsonOK(w, out)
|
||
|
||
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}
|
||
.links{margin-top:16px;text-align:center;font-size:12px;color:#666;line-height:1.8}
|
||
.links a{color:#6366f1;text-decoration:none}
|
||
.links a:hover{text-decoration:underline}</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>
|
||
<div class="links">
|
||
<a href="/forgot">Забыли пароль?</a><br>
|
||
<a href="/register">Зарегистрироваться</a> · <a href="/admin/login">Администратор?</a>
|
||
</div>
|
||
</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()">×</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()">×</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()">×</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,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||
function escJS(s){return s.replace(/'/g,"\\'").replace(/"/g,'"')}
|
||
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>`
|
||
|
||
const forgotPasswordHTML = `<!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:18px;margin:0 0 8px;text-align:center}
|
||
p{font-size:12px;color:#888;text-align:center;margin:0 0 20px}
|
||
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}
|
||
.links{text-align:center;font-size:12px;color:#666;margin-top:16px}
|
||
.links a{color:#6366f1;text-decoration:none}
|
||
.links a:hover{text-decoration:underline}</style>
|
||
</head><body>
|
||
<form method="POST">
|
||
<h1>Восстановление пароля</h1>
|
||
<p>Введите email, указанный при регистрации</p>
|
||
<label>Email</label>
|
||
<input type="email" name="email" autofocus required>
|
||
<button>Отправить ссылку</button>
|
||
<div class="links"><a href="/login">← Вспомнили пароль?</a></div>
|
||
</form>
|
||
</body></html>`
|
||
|
||
const forgotSentHTML = `<!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:18px;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>`
|
||
|
||
const resetPasswordHTML = `<!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:18px;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}
|
||
.hint{font-size:11px;color:#666;text-align:center;margin-top:12px}</style>
|
||
</head><body>
|
||
<form method="POST">
|
||
<h1>Новый пароль</h1>
|
||
<input type="hidden" name="token" value="{TOKEN}">
|
||
<label>Новый пароль</label>
|
||
<input type="password" name="password" minlength="8" required autofocus>
|
||
<label>Подтвердите пароль</label>
|
||
<input type="password" name="confirm" minlength="8" required>
|
||
<div class="hint">Минимум 8 символов, латинские буквы и цифры</div>
|
||
<button style="margin-top:8px">Сохранить</button>
|
||
</form>
|
||
</body></html>`
|
||
|
||
const resetDoneHTML = `<!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:18px;margin:0 0 12px;color:#34d399}
|
||
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
|
||
.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>
|
||
<a href="/login" class="btn">Войти</a>
|
||
</div>
|
||
</body></html>`
|