Back"))
}
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("ru")
if host == "" {
regMsg = registrationAutoHTML("ru")
}
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("ru")))
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("ru", i18n.T("ru", "common.error"), i18n.T("ru", "server.needEmail"), "/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("ru")))
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("ru")))
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("ru"), "{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("ru", i18n.T("ru", "common.error"), i18n.T("ru", "server.allFieldsRequired"), "/forgot")))
return
}
if err := validatePassword(newPass); err != "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(errorPageHTML("ru", i18n.T("ru", "common.error"), err, "/reset?token="+token)))
return
}
if newPass != confirm {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(errorPageHTML("ru", i18n.T("ru", "common.error"), i18n.T("ru", "server.passwordsDoNotMatch"), "/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("ru")))
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("ru")))
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("
401 Unauthorized
Try again"))
return
}
tok := s.userTokens.Create(userID)
http.SetCookie(w, &http.Cookie{
Name: "user_session", Value: tok, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
http.Redirect(w, r, "/dashboard", http.StatusFound)
default:
jsonErr(w, 405, "method not allowed")
}
}
func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
userID, ok := s.requireUserWeb(w, r)
if !ok {
return
}
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 = "
Нет подключённых устройств. Подключите устройство из desktop-клиента Verstak.
"
} else {
for _, d := range devices {
ls := d.LastSeen
if ls == "" {
ls = "—"
}
created := d.CreatedAt
if len(created) > 10 {
created = created[:10]
}
status := "Активно"
revokeBtn := fmt.Sprintf(``, d.ID)
if d.RevokedAt != "" {
status = "Отозвано"
revokeBtn = ""
}
deviceRows += fmt.Sprintf(`