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
+
+
+
+`, 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}