diff --git a/cmd/verstak-server/server.go b/cmd/verstak-server/server.go index 9a7c902..b162c2b 100644 --- a/cmd/verstak-server/server.go +++ b/cmd/verstak-server/server.go @@ -330,6 +330,8 @@ func (s *Server) routes() *http.ServeMux { 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) @@ -403,6 +405,26 @@ func (s *Server) smtpSet(key, val string) error { return err } +func errorPageHTML(title, msg, backURL string) string { + return fmt.Sprintf(` + + +Verstak Sync — %s + + +
+

%s

+

%s

+← Назад +
+`, title, title, msg, backURL) +} + func sel(v, want string) string { if v == want { return " selected" @@ -832,9 +854,10 @@ func (s *Server) handleForgot(w http.ResponseWriter, r *http.Request) { tokenStr, userID, exp, now) host := s.smtpGet("smtp_host") if host != "" { - resetURL := fmt.Sprintf("%s/reset?token=%s", s.smtpGet("server_url"), tokenStr) - if resetURL == "" { - resetURL = fmt.Sprintf("/api/v1/auth/reset?token=%s", tokenStr) + 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) @@ -1221,6 +1244,121 @@ func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) { } } +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 := fmt.Sprintf(resetPasswordHTML, 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": @@ -1886,7 +2024,10 @@ 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} +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}

Verstak Sync

@@ -1895,7 +2036,10 @@ button:hover{background:#4f46e5} -

Зарегистрироваться · Администратор?

+
` @@ -2160,3 +2304,91 @@ a{color:#6366f1;text-decoration:none} Войти ` + +const forgotPasswordHTML = ` + + +Verstak Sync — Восстановление пароля + + +
+

Восстановление пароля

+

Введите email, указанный при регистрации

+ + + + +
+` + +const forgotSentHTML = ` + + +Verstak Sync — Письмо отправлено + + +
+

✓ Письмо отправлено

+

Если указанный email зарегистрирован, на него придёт ссылка для сброса пароля.

+На главную +
+` + +const resetPasswordHTML = ` + + +Verstak Sync — Новый пароль + + +
+

Новый пароль

+ + + + + +
Минимум 8 символов, латинские буквы и цифры
+ +
+` + +const resetDoneHTML = ` + + +Verstak Sync — Пароль изменён + + +
+

✓ Пароль изменён

+

Теперь вы можете войти с новым паролем.

+Войти +
+`