381 lines
14 KiB
Go
381 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"verstak/internal/i18n"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
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("ru")))
|
|
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("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("<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)
|
|
}
|