diff --git a/backend-go/README.md b/backend-go/README.md index 033ae58..105153a 100644 --- a/backend-go/README.md +++ b/backend-go/README.md @@ -12,4 +12,10 @@ Option 2: Two separate terminals Terminal 1 (API server): cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/api Terminal 2 (Poller): -cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/poller \ No newline at end of file +cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/poller + +make run — Builds and runs the API server. +make dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed). +make poller — Builds and runs the poller. +make poller-dev — Runs the poller with auto-reload. +make dev-all — Runs both the API and poller concurrently. \ No newline at end of file diff --git a/backend-go/cmd/api/main.go b/backend-go/cmd/api/main.go index 31d87a8..8c6c597 100644 --- a/backend-go/cmd/api/main.go +++ b/backend-go/cmd/api/main.go @@ -44,6 +44,7 @@ func main() { defer database.Close() + userModel := models.NewUserModel(pool) addressModel := models.NewAddressModel(pool) alertRuleModel := models.NewAlertRuleModel(pool) alertEventModel := models.NewAlertEventModel(pool) @@ -61,6 +62,8 @@ func main() { emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel) statusHandler := handlers.NewStatusHandler(checkpointModel) + authenticate := middleware.Authenticate(userModel) + mux := http.NewServeMux() b := cfg.APIBasePath // e.g. "/v1" @@ -70,43 +73,43 @@ func main() { // Authenticated routes — addresses mux.Handle("POST "+b+"/addresses", - middleware.Authenticate(http.HandlerFunc(addressHandler.Create))) + authenticate(http.HandlerFunc(addressHandler.Create))) mux.Handle("GET "+b+"/addresses", - middleware.Authenticate(http.HandlerFunc(addressHandler.List))) + authenticate(http.HandlerFunc(addressHandler.List))) mux.Handle("DELETE "+b+"/addresses/{addressId}", - middleware.Authenticate(http.HandlerFunc(addressHandler.Remove))) + authenticate(http.HandlerFunc(addressHandler.Remove))) mux.Handle("PATCH "+b+"/addresses/{addressId}", - middleware.Authenticate(http.HandlerFunc(addressHandler.UpdateLabel))) + authenticate(http.HandlerFunc(addressHandler.UpdateLabel))) // Authenticated routes for alert rules mux.Handle("POST "+b+"/addresses/{addressId}/alerts", - middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create))) + authenticate(http.HandlerFunc(alertRuleHandler.Create))) mux.Handle("GET "+b+"/addresses/{addressId}/alerts", - middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress))) + authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress))) mux.Handle("PATCH "+b+"/alerts/{alertId}", - middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus))) + authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus))) mux.Handle("DELETE "+b+"/alerts/{alertId}", - middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove))) + authenticate(http.HandlerFunc(alertRuleHandler.Remove))) // Authenticated routes — alert events mux.Handle("GET "+b+"/alert-events", - middleware.Authenticate(http.HandlerFunc(alertEventHandler.List))) + authenticate(http.HandlerFunc(alertEventHandler.List))) // Authenticated routes — notification config mux.Handle("GET "+b+"/notification-config", - middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig))) + authenticate(http.HandlerFunc(notifConfigHandler.GetConfig))) mux.Handle("PUT "+b+"/notification-config", - middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig))) + authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig))) mux.Handle("DELETE "+b+"/notification-config", - middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig))) + authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig))) mux.Handle("POST "+b+"/notification-config/test", - middleware.Authenticate(http.HandlerFunc(notifConfigHandler.TestChannels))) + authenticate(http.HandlerFunc(notifConfigHandler.TestChannels))) // Authenticated routes — email digest mux.Handle("POST "+b+"/email/setup", - middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail))) + authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail))) mux.Handle("POST "+b+"/email/digest", - middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SendDigest))) + authenticate(http.HandlerFunc(emailDigestHandler.SendDigest))) handler := corsMiddleware(mux) diff --git a/backend-go/infra/migrations/005_create_users_table.sql b/backend-go/infra/migrations/005_create_users_table.sql new file mode 100644 index 0000000..8d41741 --- /dev/null +++ b/backend-go/infra/migrations/005_create_users_table.sql @@ -0,0 +1,13 @@ +-- Migration 005: Create users table with UUID primary key +-- This establishes a local user record for each Firebase-authenticated user. + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + firebase_uid VARCHAR(128) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL, + display_name VARCHAR(255), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_users_firebase_uid ON users(firebase_uid); diff --git a/backend-go/infra/migrations/006_migrate_user_ids_to_uuid.sql b/backend-go/infra/migrations/006_migrate_user_ids_to_uuid.sql new file mode 100644 index 0000000..d6c0fe4 --- /dev/null +++ b/backend-go/infra/migrations/006_migrate_user_ids_to_uuid.sql @@ -0,0 +1,73 @@ +-- Migration 006: Migrate user_id columns from Firebase UID strings to user UUIDs +-- +-- Prerequisites: migration 005 (users table) must be applied first. +-- This migration backfills the users table from existing data, then swaps the +-- VARCHAR user_id columns for UUID foreign keys referencing users(id). + +BEGIN; + +-- 1. Backfill users table from existing Firebase UIDs in addresses +INSERT INTO users (firebase_uid, email) + SELECT DISTINCT user_id, '' + FROM addresses + WHERE user_id IS NOT NULL +ON CONFLICT (firebase_uid) DO NOTHING; + +-- 2. Backfill from notification configs (catches users with configs but no addresses) +INSERT INTO users (firebase_uid, email) + SELECT DISTINCT user_id, '' + FROM user_notification_configs + WHERE user_id IS NOT NULL +ON CONFLICT (firebase_uid) DO NOTHING; + +-- ============================================================ +-- 3. Migrate addresses.user_id from VARCHAR to UUID +-- ============================================================ + +ALTER TABLE addresses ADD COLUMN user_uuid UUID; + +UPDATE addresses a +SET user_uuid = u.id +FROM users u +WHERE u.firebase_uid = a.user_id; + +-- Drop old constraints and column +ALTER TABLE addresses DROP CONSTRAINT IF EXISTS addresses_user_id_address_key; +DROP INDEX IF EXISTS idx_addresses_user_id; +ALTER TABLE addresses DROP COLUMN user_id; + +-- Rename and constrain +ALTER TABLE addresses RENAME COLUMN user_uuid TO user_id; +ALTER TABLE addresses ALTER COLUMN user_id SET NOT NULL; +ALTER TABLE addresses ADD CONSTRAINT fk_addresses_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE addresses ADD CONSTRAINT addresses_user_id_address_key + UNIQUE (user_id, address); +CREATE INDEX idx_addresses_user_id ON addresses(user_id); + +-- ============================================================ +-- 4. Migrate user_notification_configs.user_id from VARCHAR to UUID +-- ============================================================ + +-- Drop the PK first (it's on user_id) +ALTER TABLE user_notification_configs DROP CONSTRAINT IF EXISTS user_notification_configs_pkey; +DROP INDEX IF EXISTS idx_notification_configs_enabled; + +ALTER TABLE user_notification_configs ADD COLUMN user_uuid UUID; + +UPDATE user_notification_configs nc +SET user_uuid = u.id +FROM users u +WHERE u.firebase_uid = nc.user_id; + +ALTER TABLE user_notification_configs DROP COLUMN user_id; +ALTER TABLE user_notification_configs RENAME COLUMN user_uuid TO user_id; +ALTER TABLE user_notification_configs ALTER COLUMN user_id SET NOT NULL; +ALTER TABLE user_notification_configs ADD CONSTRAINT user_notification_configs_pkey + PRIMARY KEY (user_id); +ALTER TABLE user_notification_configs ADD CONSTRAINT fk_notification_configs_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +CREATE INDEX idx_notification_configs_enabled + ON user_notification_configs(notification_enabled); + +COMMIT; diff --git a/backend-go/infra/schema.sql b/backend-go/infra/schema.sql index 0b55178..9a32335 100644 --- a/backend-go/infra/schema.sql +++ b/backend-go/infra/schema.sql @@ -4,15 +4,24 @@ DROP TABLE IF EXISTS alert_rules CASCADE; DROP TABLE IF EXISTS address_checkpoints CASCADE; DROP TABLE IF EXISTS user_notification_configs CASCADE; DROP TABLE IF EXISTS addresses CASCADE; +DROP TABLE IF EXISTS users CASCADE; + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + firebase_uid VARCHAR(128) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL, + display_name VARCHAR(255), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); CREATE TABLE addresses ( id SERIAL PRIMARY KEY, - user_id VARCHAR(128) NOT NULL, -- Firebase user ID (multi-user support) + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, address VARCHAR(42) NOT NULL, -- Ethereum address format (0x + 40 hex chars) label VARCHAR(255), -- Optional human-readable label created_at TIMESTAMP DEFAULT NOW(), - - -- Unique users can track the same address independently + UNIQUE(user_id, address) ); @@ -51,7 +60,7 @@ CREATE TABLE address_checkpoints ( ); CREATE TABLE user_notification_configs ( - user_id VARCHAR(128) PRIMARY KEY, + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, discord_webhook_url TEXT, -- Discord webhook URL (nullable) telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable) telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable) @@ -63,6 +72,7 @@ CREATE TABLE user_notification_configs ( ); -- Create indexes for common queries +CREATE INDEX idx_users_firebase_uid ON users(firebase_uid); CREATE INDEX idx_addresses_user_id ON addresses(user_id); CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id); CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled); diff --git a/backend-go/internal/domain/types.go b/backend-go/internal/domain/types.go index dfa5201..2c3fb6a 100644 --- a/backend-go/internal/domain/types.go +++ b/backend-go/internal/domain/types.go @@ -2,6 +2,15 @@ package domain import "time" +type User struct { + ID string `json:"id"` + FirebaseUID string `json:"-"` + Email string `json:"email"` + DisplayName *string `json:"display_name"` //nolint:tagliatelle + CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle + UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle +} + type Address struct { ID int `json:"id"` UserID string `json:"user_id"` //nolint:tagliatelle diff --git a/backend-go/internal/middleware/auth.go b/backend-go/internal/middleware/auth.go index c06784c..194ec02 100644 --- a/backend-go/internal/middleware/auth.go +++ b/backend-go/internal/middleware/auth.go @@ -9,6 +9,7 @@ import ( "strings" fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase" + "github.com/kjannette/koin-ping/backend-go/internal/models" ) type contextKey string @@ -26,62 +27,77 @@ type errorResponse struct { func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) - json.NewEncoder(w).Encode(v) + json.NewEncoder(w).Encode(v) //nolint:errcheck } -func Authenticate(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { - log.Println("No Authorization header or invalid format") - writeJSON(w, http.StatusUnauthorized, errorResponse{ - Error: "UNAUTHORIZED", - Message: "No authentication token provided", - }) - return - } - - token := strings.TrimPrefix(authHeader, "Bearer ") - if token == "" { - log.Println("Empty token") - writeJSON(w, http.StatusUnauthorized, errorResponse{ - Error: "UNAUTHORIZED", - Message: "Invalid token format", - }) - return - } - - log.Println("Verifying Firebase token...") - decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token) - if err != nil { - log.Printf("Token verification failed: %v", err) - - errMsg := err.Error() - if strings.Contains(errMsg, "expired") { +// Authenticate verifies the Firebase ID token and auto-provisions a local user +// record. The local user UUID (not the Firebase UID) is placed into context so +// all downstream handlers use it as the canonical user identifier. +func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + log.Println("No Authorization header or invalid format") writeJSON(w, http.StatusUnauthorized, errorResponse{ - Error: "TOKEN_EXPIRED", - Message: "Authentication token has expired", + Error: "UNAUTHORIZED", + Message: "No authentication token provided", }) return } - writeJSON(w, http.StatusUnauthorized, errorResponse{ - Error: "UNAUTHORIZED", - Message: "Failed to verify authentication token", - }) - return - } + token := strings.TrimPrefix(authHeader, "Bearer ") + if token == "" { + log.Println("Empty token") + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "Invalid token format", + }) + return + } - userID := decoded.UID - email, _ := decoded.Claims["email"].(string) + log.Println("Verifying Firebase token...") + decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token) + if err != nil { + log.Printf("Token verification failed: %v", err) - log.Printf("Token verified! User ID: %s, Email: %s", userID, email) + errMsg := err.Error() + if strings.Contains(errMsg, "expired") { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "TOKEN_EXPIRED", + Message: "Authentication token has expired", + }) + return + } - ctx := context.WithValue(r.Context(), UserIDKey, userID) - ctx = context.WithValue(ctx, UserEmailKey, email) + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "Failed to verify authentication token", + }) + return + } - next.ServeHTTP(w, r.WithContext(ctx)) - }) + firebaseUID := decoded.UID + email, _ := decoded.Claims["email"].(string) + + user, err := userModel.FindOrCreateByFirebaseUID(r.Context(), firebaseUID, email) + if err != nil { + log.Printf("Failed to provision local user for Firebase UID %s: %v", firebaseUID, err) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL_ERROR", + Message: "Failed to initialize user account", + }) + return + } + + log.Printf("Token verified! User UUID: %s, Email: %s", user.ID, email) + + ctx := context.WithValue(r.Context(), UserIDKey, user.ID) + ctx = context.WithValue(ctx, UserEmailKey, email) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } } func GetUserID(ctx context.Context) string { diff --git a/backend-go/internal/models/user.go b/backend-go/internal/models/user.go new file mode 100644 index 0000000..60db4d5 --- /dev/null +++ b/backend-go/internal/models/user.go @@ -0,0 +1,53 @@ +package models + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/kjannette/koin-ping/backend-go/internal/domain" +) + +type UserModel struct { + pool *pgxpool.Pool +} + +func NewUserModel(pool *pgxpool.Pool) *UserModel { + return &UserModel{pool: pool} +} + +// FindOrCreateByFirebaseUID returns the local user for a Firebase UID, +// creating one if it doesn't exist yet. On conflict (returning user) the +// updated_at timestamp is refreshed. +func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) { + var u domain.User + err := m.pool.QueryRow(ctx, + `INSERT INTO users (firebase_uid, email) + VALUES ($1, $2) + ON CONFLICT (firebase_uid) DO UPDATE SET updated_at = NOW() + RETURNING id, firebase_uid, email, display_name, created_at, updated_at`, + firebaseUID, email, + ).Scan(&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.CreatedAt, &u.UpdatedAt) + if err != nil { + return nil, err + } + return &u, nil +} + +func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) { + var u domain.User + err := m.pool.QueryRow(ctx, + `SELECT id, firebase_uid, email, display_name, created_at, updated_at + FROM users + WHERE id = $1`, + id, + ).Scan(&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.CreatedAt, &u.UpdatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &u, nil +} diff --git a/frontend/src/pages/Login.css b/frontend/src/pages/Login.css index be5f67c..9dd7ff2 100644 --- a/frontend/src/pages/Login.css +++ b/frontend/src/pages/Login.css @@ -19,6 +19,8 @@ .login-heading { margin-bottom: 2rem; text-align: center; + position: relative; + top: -20px; } .login-brand {