verstak/cmd/verstak-server/handlers_user.go

542 lines
16 KiB
Go

package main
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"verstak/internal/i18n"
"golang.org/x/crypto/bcrypt"
)
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(i18n.T(s.locale(), "server.emailConfirmBody"), confirmURL)
if err := s.smtpSend(req.Email, i18n.T(s.locale(), "server.emailConfirmSubject"), 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(s.locale())))
}
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(i18n.T(s.locale(), "server.emailResetBody"), resetURL)
s.smtpSend(req.Email, i18n.T(s.locale(), "server.emailResetSubject"), 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")
}
}