package main import ( "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" "time" "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("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(req.Email, "Confirm your Verstak Sync account", 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("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) } 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") } }