diff --git a/README.md b/README.md index 2111c71..49a50e2 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,19 @@ Nail salon booking platform — Go 1.25 backend + SvelteKit 5 frontend + Docker. - **Admin login redirect**: admins are redirected to `/today` after login instead of the home page - **Patch test duration on services**: `patch_test_duration_hours` exposed on Service type; creating a service with duration > 0 auto-creates a patch test record - **`created_by_name` on bookings**: admin booking details now show who created the booking (admin name) +- **Exceptional scheduling fix**: exceptional application queries expand start to Monday of the week, fixing single-day queries missing Monday-based `week_start` records +- **Portfolio image upload limits**: 20MB file size limit with visual feedback (red borders, error text), upload button disabled for oversized files +- **NavBar enhancements**: admin links reordered (Today/Schedule promoted), unread notification badge on mobile burger icon, backdrop overlay + slide transition for mobile menu +- **TodayCalendar improvements**: fetches working/available hours for full week range instead of single day, closing time indicator on timeline, skips lunch suggestion for short days (5h or less) +- **Lunch protection refinement**: `shouldApplyLunchProtection()` skips lunch protection for days with 5 or fewer working hours +- **`formatDateISO` utility**: new `formatDateISO(date)` function in `lib/utils/format.ts` returning YYYY-MM-DD format for API calls, later extracted to shared utilities +- **JWT revocation with JTI**: every JWT carries a unique `jti` claim (UUID v4), in-memory revoked JTI tracking with 5-minute cleanup ticker, `POST /api/logout` endpoint revokes current token, refresh handler revokes old JTI before issuing replacement +- **Portfolio image deletion fix**: `extractKey` correctly extracts full S3 key path from URLs instead of just the filename, preventing orphaned files in storage +- **Notes validation**: all `Notes *string` fields across booking structs validated with `max=1000000` tag (13 fields across 4 files) +- **CharCounter component**: reusable grapheme counter using `Intl.Segmenter`, shows counter only above 750K graphemes, color-coded (green <800K, yellow 800K-950K, red >950K), integrated into 6 booking/admin components +- **loginInProgress rate limiting**: switched from `map[string]bool` to `map[string]time.Time` with 30-second staleness check, 20-entry cap (returns 429 when full), ticker goroutine cleans up stuck entries +- **Profile picture upload limit**: 15MB client-side check before crop dialog in account page +- **Portfolio image upload backend limit**: separate `portfolioBodyLimit` (20MB) applied to `/images` route, distinct from `uploadBodyLimit` (15MB) for profile pictures ## Project Structure diff --git a/backend/auth/jwt.go b/backend/auth/jwt.go index 8b81133..3908054 100644 --- a/backend/auth/jwt.go +++ b/backend/auth/jwt.go @@ -2,7 +2,9 @@ package auth import ( "context" + "crypto/rand" "fmt" + "sync" "time" "github.com/go-chi/jwtauth/v5" @@ -13,46 +15,135 @@ var TokenAuth *jwtauth.JWTAuth // AuthResponse is the response structure for login/refresh endpoints type AuthResponse struct { Token string `json:"token"` + JTI string `json:"jti"` +} + +// In-memory revoked JTI tracking +var ( + revokedJTIs = make(map[string]time.Time) + revokedJTIsMu sync.RWMutex +) + +// generateJTI generates a UUID v4 string using crypto/rand +// Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx +func generateJTI() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate JTI: %w", err) + } + + // Set version 4 bits + b[6] = (b[6] & 0x0f) | 0x40 + // Set variant bits (10xx) + b[8] = (b[8] & 0x3f) | 0x80 + + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil +} + +// RevokeJTI adds a JTI to the revoked set with its expiry time +func RevokeJTI(jti string, expiresAt time.Time) { + revokedJTIsMu.Lock() + defer revokedJTIsMu.Unlock() + revokedJTIs[jti] = expiresAt +} + +// IsJTIRevoked checks if a JTI is in the revoked set +func IsJTIRevoked(jti string) bool { + revokedJTIsMu.RLock() + defer revokedJTIsMu.RUnlock() + _, revoked := revokedJTIs[jti] + return revoked +} + +// CleanupRevokedJTIs removes entries where the expiry time has passed +func CleanupRevokedJTIs() { + revokedJTIsMu.Lock() + defer revokedJTIsMu.Unlock() + now := time.Now() + for jti, expiresAt := range revokedJTIs { + if now.After(expiresAt) { + delete(revokedJTIs, jti) + } + } +} + +func init() { + go func() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for range ticker.C { + CleanupRevokedJTIs() + } + }() } func InitJWT(secret string) { TokenAuth = jwtauth.New("HS256", []byte(secret), nil) } -// GenerateToken creates a JWT with user_id and role -func GenerateToken(userID string, role string) (string, error) { - _, tokenString, err := TokenAuth.Encode(map[string]interface{}{ - "user_id": userID, - "role": role, - "exp": time.Now().Add(30 * 24 * time.Hour).Unix(), // 30 days - }) - return tokenString, err -} - -// VerifyToken validates JWT and returns user_id and role -func VerifyToken(tokenString string, ctx context.Context) (userID string, role string, err error) { - token, err := TokenAuth.Decode(tokenString) +// GenerateToken creates a JWT with user_id, role, and a unique jti claim +// Returns the token string, the JTI, and any error +func GenerateToken(userID string, role string) (string, string, error) { + jti, err := generateJTI() if err != nil { return "", "", err } + _, tokenString, err := TokenAuth.Encode(map[string]interface{}{ + "user_id": userID, + "role": role, + "jti": jti, + "exp": time.Now().Add(30 * 24 * time.Hour).Unix(), // 30 days + }) + return tokenString, jti, err +} + +// VerifyToken validates JWT and returns user_id, role, and jti +func VerifyToken(tokenString string, ctx context.Context) (userID string, role string, jti string, err error) { + token, err := TokenAuth.Decode(tokenString) + if err != nil { + return "", "", "", err + } + var uidVal interface{} if err := token.Get("user_id", &uidVal); err != nil { - return "", "", fmt.Errorf("invalid user_id claim") + return "", "", "", fmt.Errorf("invalid user_id claim") } userID, ok := uidVal.(string) if !ok { - return "", "", fmt.Errorf("invalid user_id claim") + return "", "", "", fmt.Errorf("invalid user_id claim") } var roleVal interface{} if err := token.Get("role", &roleVal); err != nil { - return "", "", fmt.Errorf("invalid role claim") + return "", "", "", fmt.Errorf("invalid role claim") } role, ok = roleVal.(string) if !ok { - return "", "", fmt.Errorf("invalid role claim") + return "", "", "", fmt.Errorf("invalid role claim") } - return userID, role, nil + var jtiVal interface{} + if err := token.Get("jti", &jtiVal); err != nil { + return "", "", "", fmt.Errorf("invalid jti claim") + } + jti, ok = jtiVal.(string) + if !ok || jti == "" { + return "", "", "", fmt.Errorf("invalid jti claim") + } + + if IsJTIRevoked(jti) { + return "", "", "", fmt.Errorf("token revoked") + } + jti, ok = jtiVal.(string) + if !ok || jti == "" { + return "", "", "", fmt.Errorf("invalid jti claim") + } + + if IsJTIRevoked(jti) { + return "", "", "", fmt.Errorf("token revoked") + } + + return userID, role, jti, nil } diff --git a/backend/auth/jwt_test.go b/backend/auth/jwt_test.go new file mode 100644 index 0000000..5fff2e6 --- /dev/null +++ b/backend/auth/jwt_test.go @@ -0,0 +1,276 @@ +//go:build test +// +build test + +package auth + +// Package auth contains tests for JWT generation, verification, and JTI revocation. +// +// Test Coverage: +// - generateJTI: UUID v4 format validation, uniqueness +// - GenerateToken: returns non-empty token and JTI, JTI matches claim +// - VerifyToken: returns correct user_id/role/JTI, rejects revoked/missing JTI +// - RevokeJTI: adds to revoked set, IsJTIRevoked reflects changes +// - CleanupRevokedJTIs: removes expired JTIs, keeps valid ones + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +func TestMain(m *testing.M) { + InitJWT("test-secret-key-for-jwt-test") + code := m.Run() + os.Exit(code) +} + +// ============================================================================= +// generateJTI Tests +// ============================================================================= + +// TestGenerateJTI_Format verifies that generateJTI returns a valid UUID v4 string +// in the format: 8-4-4-4-12 hexadecimal digits with version and variant bits set. +func TestGenerateJTI_Format(t *testing.T) { + jti, err := generateJTI() + if err != nil { + t.Fatalf("generateJTI() failed: %v", err) + } + + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + parts := strings.Split(jti, "-") + if len(parts) != 5 { + t.Errorf("expected 5 parts in UUID, got %d: %s", len(parts), jti) + } + + // Check each part length: 8-4-4-4-12 + expectedLengths := []int{8, 4, 4, 4, 12} + for i, part := range parts { + if len(part) != expectedLengths[i] { + t.Errorf("part %d expected length %d, got %d (jti=%s)", i, expectedLengths[i], len(part), jti) + } + } + + // Check version nibble (4xxx) in the third group + if len(parts[2]) > 0 && parts[2][0] != '4' { + t.Errorf("expected version 4 UUID, got version %c in jti=%s", parts[2][0], jti) + } + + // Check variant bits (8xxx, 9xxx, axxx, or bxxx) in the fourth group + if len(parts[3]) > 0 { + c := parts[3][0] + if c != '8' && c != '9' && c != 'a' && c != 'b' { + t.Errorf("expected variant bits 10xx, got %c in jti=%s", c, jti) + } + } +} + +// TestGenerateJTI_Unique generates 100 JTIs and verifies all are unique. +func TestGenerateJTI_Unique(t *testing.T) { + seen := make(map[string]bool) + for i := 0; i < 100; i++ { + jti, err := generateJTI() + if err != nil { + t.Fatalf("generateJTI() failed at iteration %d: %v", i, err) + } + if seen[jti] { + t.Errorf("duplicate JTI generated at iteration %d: %s", i, jti) + } + seen[jti] = true + } + + if len(seen) != 100 { + t.Errorf("expected 100 unique JTIs, got %d", len(seen)) + } +} + +// ============================================================================= +// GenerateToken Tests +// ============================================================================= + +// TestGenerateToken_ReturnsJTI verifies that GenerateToken returns both a +// non-empty token string and a non-empty JTI string. +func TestGenerateToken_ReturnsJTI(t *testing.T) { + token, jti, err := GenerateToken("user-001", "verified_email") + if err != nil { + t.Fatalf("GenerateToken() failed: %v", err) + } + if token == "" { + t.Error("expected non-empty token") + } + if jti == "" { + t.Error("expected non-empty JTI") + } +} + +// TestGenerateToken_JTIInClaims decodes the generated token and verifies +// the "jti" claim matches the returned JTI value. +func TestGenerateToken_JTIInClaims(t *testing.T) { + token, jti, err := GenerateToken("user-002", "admin") + if err != nil { + t.Fatalf("GenerateToken() failed: %v", err) + } + + decoded, err := TokenAuth.Decode(token) + if err != nil { + t.Fatalf("failed to decode token: %v", err) + } + + var claimJTI string + if err := decoded.Get("jti", &claimJTI); err != nil { + t.Fatalf("failed to get jti claim: %v", err) + } + + if claimJTI != jti { + t.Errorf("expected jti claim %q, got %q", jti, claimJTI) + } +} + +// ============================================================================= +// VerifyToken Tests +// ============================================================================= + +// TestVerifyToken_ReturnsJTI creates a token, verifies it, and checks the +// returned user_id, role, and JTI match the expected values. +func TestVerifyToken_ReturnsJTI(t *testing.T) { + token, jti, err := GenerateToken("user-003", "verified_email") + if err != nil { + t.Fatalf("GenerateToken() failed: %v", err) + } + + userID, role, returnedJTI, err := VerifyToken(token, context.Background()) + if err != nil { + t.Fatalf("VerifyToken() failed: %v", err) + } + + if userID != "user-003" { + t.Errorf("expected userID 'user-003', got %q", userID) + } + if role != "verified_email" { + t.Errorf("expected role 'verified_email', got %q", role) + } + if returnedJTI != jti { + t.Errorf("expected JTI %q, got %q", jti, returnedJTI) + } +} + +// TestVerifyToken_RevokedJTI creates a token, revokes its JTI, and verifies +// that VerifyToken returns an error containing "token revoked". +func TestVerifyToken_RevokedJTI(t *testing.T) { + token, jti, err := GenerateToken("user-004", "verified_email") + if err != nil { + t.Fatalf("GenerateToken() failed: %v", err) + } + + RevokeJTI(jti, time.Now().Add(30*24*time.Hour)) + + _, _, _, err = VerifyToken(token, context.Background()) + if err == nil { + t.Fatal("expected error for revoked JTI, got nil") + } + if !strings.Contains(err.Error(), "token revoked") { + t.Errorf("expected 'token revoked' error, got: %v", err) + } +} + +// TestVerifyToken_MissingJTI creates a token without a "jti" claim (using +// TokenAuth.Encode directly) and verifies that VerifyToken returns an error +// containing "invalid jti claim". +func TestVerifyToken_MissingJTI(t *testing.T) { + _, tokenString, err := TokenAuth.Encode(map[string]interface{}{ + "user_id": "user-005", + "role": "verified_email", + "exp": time.Now().Add(30 * 24 * time.Hour).Unix(), + }) + if err != nil { + t.Fatalf("failed to create token without JTI: %v", err) + } + + _, _, _, err = VerifyToken(tokenString, context.Background()) + if err == nil { + t.Fatal("expected error for missing JTI, got nil") + } + if !strings.Contains(err.Error(), "invalid jti claim") { + t.Errorf("expected 'invalid jti claim' error, got: %v", err) + } +} + +// ============================================================================= +// RevokeJTI / IsJTIRevoked Tests +// ============================================================================= + +// TestRevokeJTI_AddsToSet verifies that calling RevokeJTI adds the JTI to the +// revoked set, and IsJTIRevoked returns true for it. +func TestRevokeJTI_AddsToSet(t *testing.T) { + _, jti, err := GenerateToken("user-006", "verified_email") + if err != nil { + t.Fatalf("GenerateToken() failed: %v", err) + } + + if IsJTIRevoked(jti) { + t.Fatal("JTI should not be revoked before calling RevokeJTI") + } + + RevokeJTI(jti, time.Now().Add(30*24*time.Hour)) + + if !IsJTIRevoked(jti) { + t.Error("expected IsJTIRevoked to return true after RevokeJTI") + } +} + +// TestIsJTIRevoked_NonExistent verifies that checking a non-existent JTI +// returns false. +func TestIsJTIRevoked_NonExistent(t *testing.T) { + if IsJTIRevoked("nonexistent-jti-12345") { + t.Error("expected IsJTIRevoked to return false for non-existent JTI") + } +} + +// ============================================================================= +// CleanupRevokedJTIs Tests +// ============================================================================= + +// TestCleanupRevokedJTIs_RemovesExpired adds a JTI with a past expiry time, +// runs CleanupRevokedJTIs, and verifies the JTI is removed from the set. +func TestCleanupRevokedJTIs_RemovesExpired(t *testing.T) { + _, jti, err := GenerateToken("user-007", "verified_email") + if err != nil { + t.Fatalf("GenerateToken() failed: %v", err) + } + + // Add with past expiry (1 hour ago) + RevokeJTI(jti, time.Now().Add(-1*time.Hour)) + + if !IsJTIRevoked(jti) { + t.Fatal("JTI should be in revoked set before cleanup") + } + + CleanupRevokedJTIs() + + if IsJTIRevoked(jti) { + t.Error("expected expired JTI to be removed after cleanup") + } +} + +// TestCleanupRevokedJTIs_KeepsValid adds a JTI with a future expiry time, +// runs CleanupRevokedJTIs, and verifies the JTI is still in the set. +func TestCleanupRevokedJTIs_KeepsValid(t *testing.T) { + _, jti, err := GenerateToken("user-008", "verified_email") + if err != nil { + t.Fatalf("GenerateToken() failed: %v", err) + } + + // Add with future expiry + RevokeJTI(jti, time.Now().Add(30*24*time.Hour)) + + if !IsJTIRevoked(jti) { + t.Fatal("JTI should be in revoked set before cleanup") + } + + CleanupRevokedJTIs() + + if !IsJTIRevoked(jti) { + t.Error("expected valid (future expiry) JTI to remain after cleanup") + } +} diff --git a/backend/handlers/auth/auth_test.go b/backend/handlers/auth/auth_test.go index 88e27c6..767bc9b 100644 --- a/backend/handlers/auth/auth_test.go +++ b/backend/handlers/auth/auth_test.go @@ -31,6 +31,7 @@ import ( "testing" "time" + "crussell/auth" "crussell/db" "crussell/internal/dav" "crussell/mw" @@ -38,6 +39,7 @@ import ( "crussell/testutils/jwt" "crussell/testutils/testdb" + "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -1105,5 +1107,291 @@ func TestRegister_WithInvalidReferralCodeFormat(t *testing.T) { } } +// ============================================================================= +// Logout Handler Tests +// ============================================================================= + +// TestLogoutHandler_Success verifies that a valid logout request returns +// 200 OK with {"success": true} and revokes the JTI. +func TestLogoutHandler_Success(t *testing.T) { + resetTestData(t) + + // Create a test user + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + // Generate a token and get its JTI + token, jti, err := auth.GenerateToken(userID, "verified_email") + if err != nil { + t.Fatalf("failed to generate token: %v", err) + } + + // Create request with JTI in context (simulating RequireAuth middleware) + req := httptest.NewRequest("POST", "/api/logout", nil) + req.Header.Set("Authorization", "Bearer "+token) + ctx := context.WithValue(req.Context(), mw.JTIKey, jti) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + LogoutHandler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp struct { + Success bool `json:"success"` + } + if err := parseResponseBody(w, &resp); err != nil { + t.Errorf("failed to parse response: %v", err) + } + if !resp.Success { + t.Error("expected success=true in response") + } + + // Verify JTI was revoked + if !auth.IsJTIRevoked(jti) { + t.Error("expected JTI to be revoked after logout") + } +} + +// TestLogoutHandler_RevokesJTI verifies that after logout, the token's JTI is +// revoked and the token can no longer be used with authenticated endpoints. +func TestLogoutHandler_RevokesJTI(t *testing.T) { + resetTestData(t) + + // Create a test user + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + // Generate a token and get its JTI + token, jti, err := auth.GenerateToken(userID, "verified_email") + if err != nil { + t.Fatalf("failed to generate token: %v", err) + } + + // Call logout with JTI in context + req := httptest.NewRequest("POST", "/api/logout", nil) + req.Header.Set("Authorization", "Bearer "+token) + ctx := context.WithValue(req.Context(), mw.JTIKey, jti) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + LogoutHandler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("logout failed: %d. body: %s", w.Code, w.Body.String()) + } + + // Try to use the same token through RequireAuth middleware + router := chi.NewRouter() + router.With(mw.RequireAuth).Get("/api/protected", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + req2 := httptest.NewRequest("GET", "/api/protected", nil) + req2.Header.Set("Authorization", "Bearer "+token) + w2 := httptest.NewRecorder() + router.ServeHTTP(w2, req2) + + if w2.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for revoked token, got %d", w2.Code) + } +} + +// TestLogoutHandler_NoToken verifies that calling logout without an +// Authorization header returns 401. +func TestLogoutHandler_NoToken(t *testing.T) { + resetTestData(t) + + req := httptest.NewRequest("POST", "/api/logout", nil) + w := httptest.NewRecorder() + + // Without JTI in context, handler returns 401 + LogoutHandler(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestLogoutHandler_InvalidToken verifies that calling logout with an empty +// JTI returns 401. +func TestLogoutHandler_InvalidToken(t *testing.T) { + resetTestData(t) + + req := httptest.NewRequest("POST", "/api/logout", nil) + ctx := context.WithValue(req.Context(), mw.JTIKey, "") + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + LogoutHandler(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// ============================================================================= +// Refresh Token JTI Tests +// ============================================================================= + +// TestRefreshToken_RevokesOldJTI verifies that refreshing a token revokes the +// old JTI and issues a new one. The old token becomes invalid after refresh. +func TestRefreshToken_RevokesOldJTI(t *testing.T) { + resetTestData(t) + + // Create a test user + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + // Generate initial token and JTI + oldToken, oldJTI, err := auth.GenerateToken(userID, "verified_email") + if err != nil { + t.Fatalf("failed to generate old token: %v", err) + } + + // Verify old JTI is not yet revoked + if auth.IsJTIRevoked(oldJTI) { + t.Fatal("old JTI should not be revoked before refresh") + } + + // Call refresh handler with old JTI in context + req := httptest.NewRequest("POST", "/api/refresh-token", nil) + req.Header.Set("Authorization", "Bearer "+oldToken) + ctx := req.Context() + ctx = context.WithValue(ctx, mw.UserIDKey, userID) + ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email") + ctx = context.WithValue(ctx, mw.JTIKey, oldJTI) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + RefreshTokenHandler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("refresh failed: %d. body: %s", w.Code, w.Body.String()) + } + + // Verify old JTI was revoked + if !auth.IsJTIRevoked(oldJTI) { + t.Error("expected old JTI to be revoked after refresh") + } + + // Verify old token is rejected by middleware + router := chi.NewRouter() + router.With(mw.RequireAuth).Get("/api/protected", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + req2 := httptest.NewRequest("GET", "/api/protected", nil) + req2.Header.Set("Authorization", "Bearer "+oldToken) + w2 := httptest.NewRecorder() + router.ServeHTTP(w2, req2) + + if w2.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for revoked token after refresh, got %d", w2.Code) + } +} + +// ============================================================================= +// Login Response JTI Tests +// ============================================================================= + +// TestLoginResponse_IncludesJTI verifies that the login response includes both +// "token" and "jti" fields, both non-empty. +func TestLoginResponse_IncludesJTI(t *testing.T) { + resetTestData(t) + + handler := http.HandlerFunc(LoginHandler) + + // Create a test user with known email + userID, err := fixtures.CreateTestUserWithEmail(db.DB, "jti-test@test.com", "verified_email") + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + body := LoginRequest{ + Email: "jti-test@test.com", + Password: "testpassword123", + } + + w := makeRequest(handler, "POST", "/api/login", body) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp struct { + Token string `json:"token"` + JTI string `json:"jti"` + } + if err := parseResponseBody(w, &resp); err != nil { + t.Errorf("failed to parse response: %v", err) + } + + if resp.Token == "" { + t.Error("expected non-empty token in login response") + } + if resp.JTI == "" { + t.Error("expected non-empty jti in login response") + } +} + +// ============================================================================= +// LoginInProgress Rate Limiting Tests +// ============================================================================= + +// TestLoginInProgress_Cap verifies that when the loginInProgress map is full +// (20 concurrent logins), the 21st attempt returns HTTP 429 Too Many Requests. +func TestLoginInProgress_Cap(t *testing.T) { + resetTestData(t) + + handler := http.HandlerFunc(LoginHandler) + + // Create a test user + userID, err := fixtures.CreateTestUserWithEmail(db.DB, "ratelimit@test.com", "verified_email") + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + // Fill the loginInProgress map with 20 entries + loginStateMu.Lock() + for i := 0; i < maxLoginInProgress; i++ { + loginInProgress[fmt.Sprintf("stale-user-%d", i)] = time.Now() + } + loginStateMu.Unlock() + + defer func() { + // Clean up + loginStateMu.Lock() + for i := 0; i < maxLoginInProgress; i++ { + delete(loginInProgress, fmt.Sprintf("stale-user-%d", i)) + } + loginStateMu.Unlock() + }() + + // Attempt login - should get 429 + body := LoginRequest{ + Email: "ratelimit@test.com", + Password: "testpassword123", + } + + w := makeRequest(handler, "POST", "/api/login", body) + + if w.Code != http.StatusTooManyRequests { + t.Errorf("expected status 429, got %d. body: %s", w.Code, w.Body.String()) + } +} + // Ensure test compilation - import pgxpool to avoid unused import var _ = func() *pgxpool.Pool { return nil } diff --git a/backend/handlers/auth/local.go b/backend/handlers/auth/local.go index 1fe831f..0cfdf9b 100644 --- a/backend/handlers/auth/local.go +++ b/backend/handlers/auth/local.go @@ -30,10 +30,12 @@ var ( titleCaser = cases.Title(language.English) ) +const maxLoginInProgress = 20 + // Login state management var ( loginStateMu sync.Mutex - loginInProgress = make(map[string]bool) + loginInProgress = make(map[string]time.Time) loginAttempts = make(map[string]time.Time) ) @@ -51,6 +53,12 @@ func init() { delete(loginAttempts, userID) } } + // Clean up stuck loginInProgress entries (older than 30s) + for userID, startedAt := range loginInProgress { + if now.Sub(startedAt) > 30*time.Second { + delete(loginInProgress, userID) + } + } loginStateMu.Unlock() } }() @@ -311,12 +319,18 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) { // Check if user is already logging in loginStateMu.Lock() - if loginInProgress[userID] { + if t, ok := loginInProgress[userID]; ok && time.Since(t) < 30*time.Second { loginStateMu.Unlock() http.Error(w, "login already in progress", http.StatusConflict) // 409 return } - loginInProgress[userID] = true + // Cap the map size - drop new request if at capacity + if len(loginInProgress) >= maxLoginInProgress { + loginStateMu.Unlock() + http.Error(w, "server busy, try again later", http.StatusTooManyRequests) + return + } + loginInProgress[userID] = time.Now() loginStateMu.Unlock() // Always clear flag when done @@ -363,20 +377,21 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) { } // Generate JWT - tokenString, err := auth.GenerateToken(userID, role) + tokenString, jti, err := auth.GenerateToken(userID, role) if err != nil { http.Error(w, "could not generate token", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(auth.AuthResponse{Token: tokenString}) + json.NewEncoder(w).Encode(auth.AuthResponse{Token: tokenString, JTI: jti}) } // POST /api/refresh-token (requires auth middleware) func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { userID, _ := mw.GetUserID(r.Context()) role, _ := mw.GetUserRole(r.Context()) + oldJTI, _ := mw.GetJTI(r.Context()) // Verify user still exists and role hasn't changed var currentRole string @@ -395,15 +410,36 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { return } + // Revoke the old token's JTI before issuing a new one + if oldJTI != "" { + // Use a 30-day expiry from now for the revoked JTI (matching token lifetime) + auth.RevokeJTI(oldJTI, time.Now().Add(30*24*time.Hour)) + } + // Generate new token - newToken, err := auth.GenerateToken(userID, currentRole) + newToken, jti, err := auth.GenerateToken(userID, currentRole) if err != nil { http.Error(w, "could not generate token", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(auth.AuthResponse{Token: newToken}) + json.NewEncoder(w).Encode(auth.AuthResponse{Token: newToken, JTI: jti}) +} + +// POST /api/logout (requires auth middleware) +func LogoutHandler(w http.ResponseWriter, r *http.Request) { + jti, ok := mw.GetJTI(r.Context()) + if !ok || jti == "" { + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + + // Revoke the JTI — it will be kept until the token's natural expiry (30 days) + auth.RevokeJTI(jti, time.Now().Add(30*24*time.Hour)) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"success": true}) } type VerificationCodeRequest struct { diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 1ee7a92..a87b2a0 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -33,7 +33,7 @@ type Booking struct { ID string `json:"id"` StartTime time.Time `json:"start_time"` Status string `json:"status"` - Notes *string `json:"notes,omitempty"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` CreatedBy *string `json:"created_by,omitempty"` @@ -111,7 +111,7 @@ type Payment struct { type CreateBookingRequest struct { StartTime time.Time `json:"start_time" validate:"required"` ServiceIDs []string `json:"service_ids" validate:"required,min=1"` - Notes *string `json:"notes,omitempty"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` UserID *string `json:"user_id,omitempty"` } @@ -128,7 +128,7 @@ type ProgressBookingRequest struct { // ConfirmBookingRequest represents the request payload for confirming a booking type ConfirmBookingRequest struct { ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"` - Notes *string `json:"notes,omitempty"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` } // ServiceOverride represents override values for a specific service in a booking @@ -142,7 +142,7 @@ type ServiceOverride struct { type UpdateBookingServicesRequest struct { ServiceIDs []string `json:"service_ids"` ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"` - Notes *string `json:"notes,omitempty"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` } // DeleteBookingRequest represents the request payload for deleting a booking with payment @@ -155,7 +155,7 @@ type DeleteBookingRequest struct { type AdminUserSummary struct { FullName string `json:"full_name"` ProfilePicURL *string `json:"profile_pic_url,omitempty"` - Notes *string `json:"notes,omitempty"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` } // AdminBookingSummary represents a complete booking summary for admin view @@ -184,7 +184,7 @@ type UserSummary struct { ReferralCode *string `json:"referral_code,omitempty"` ReferralCodeUses *int `json:"referral_code_uses,omitempty"` CreatedAt string `json:"created_at"` - Notes *string `json:"notes,omitempty"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` } type BookingServiceDetail struct { diff --git a/backend/handlers/bookings/bookings_test.go b/backend/handlers/bookings/bookings_test.go index b9c5fa9..17bba80 100644 --- a/backend/handlers/bookings/bookings_test.go +++ b/backend/handlers/bookings/bookings_test.go @@ -29,6 +29,7 @@ import ( "crussell/db" "crussell/handlers/user" + "crussell/internal/validators" "crussell/mw" "crussell/testutils/fixtures" "crussell/testutils/jwt" @@ -2237,6 +2238,89 @@ func TestApplyDepositsIfNeeded_DoesNotApplyAt1(t *testing.T) { } } +// ============================================================================= +// Notes Validation Tests +// ============================================================================= + +// TestNotesValidation_UnderLimit verifies that a Notes string of 999,999 +// characters passes go-playground/validator validation (max=1000000). +func TestNotesValidation_UnderLimit(t *testing.T) { + longNotes := strings.Repeat("a", 999999) + req := CreateBookingRequest{ + StartTime: time.Now().Add(72 * time.Hour), + ServiceIDs: []string{"test-service-id"}, + Notes: &longNotes, + } + + err := validators.Validate.Struct(&req) + if err != nil { + t.Errorf("expected validation to pass for 999999-char notes, got: %v", err) + } +} + +// TestNotesValidation_AtLimit verifies that a Notes string of exactly +// 1,000,000 characters passes validation (max=1000000, boundary test). +func TestNotesValidation_AtLimit(t *testing.T) { + exactNotes := strings.Repeat("b", 1000000) + req := CreateBookingRequest{ + StartTime: time.Now().Add(72 * time.Hour), + ServiceIDs: []string{"test-service-id"}, + Notes: &exactNotes, + } + + err := validators.Validate.Struct(&req) + if err != nil { + t.Errorf("expected validation to pass for 1000000-char notes (boundary), got: %v", err) + } +} + +// TestNotesValidation_OverLimit verifies that a Notes string of 1,000,001 +// characters fails validation with max=1000000. +func TestNotesValidation_OverLimit(t *testing.T) { + tooLongNotes := strings.Repeat("c", 1000001) + req := CreateBookingRequest{ + StartTime: time.Now().Add(72 * time.Hour), + ServiceIDs: []string{"test-service-id"}, + Notes: &tooLongNotes, + } + + err := validators.Validate.Struct(&req) + if err == nil { + t.Error("expected validation to fail for 1000001-char notes (over limit)") + } +} + +// TestNotesValidation_NilPointer verifies that a nil Notes pointer passes +// validation (omitempty tag allows nil/empty). +func TestNotesValidation_NilPointer(t *testing.T) { + req := CreateBookingRequest{ + StartTime: time.Now().Add(72 * time.Hour), + ServiceIDs: []string{"test-service-id"}, + Notes: nil, + } + + err := validators.Validate.Struct(&req) + if err != nil { + t.Errorf("expected validation to pass for nil Notes (omitempty), got: %v", err) + } +} + +// TestNotesValidation_EmptyString verifies that an empty string Notes passes +// validation (omitempty and max=1000000 allows empty strings). +func TestNotesValidation_EmptyString(t *testing.T) { + emptyNotes := "" + req := CreateBookingRequest{ + StartTime: time.Now().Add(72 * time.Hour), + ServiceIDs: []string{"test-service-id"}, + Notes: &emptyNotes, + } + + err := validators.Validate.Struct(&req) + if err != nil { + t.Errorf("expected validation to pass for empty string Notes, got: %v", err) + } +} + // Ensure test compilation - import pgxpool to avoid unused import var _ = func() *pgxpool.Pool { return nil } diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index ebe511e..bce1616 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -422,7 +422,7 @@ type AdminCreateBookingForUserRequest struct { StartTime time.Time `json:"start_time" validate:"required"` ServiceIDs []string `json:"service_ids" validate:"required,min=1"` ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"` - Notes *string `json:"notes,omitempty"` // appointment notes, visible to customers and staff + Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` // appointment notes, visible to customers and staff EnforceDeposits *bool `json:"enforce_deposits,omitempty"` // Optional: if true, enforce outstanding deposit checks; if false or omitted, bypass checks } @@ -860,7 +860,7 @@ type BookingEditRequest struct { RequestedBy string `json:"requested_by"` NewStartTime *time.Time `json:"new_start_time,omitempty"` NewServices []string `json:"new_services"` - Notes *string `json:"notes,omitempty"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` HasOverrides bool `json:"has_overrides"` UpdatedAt time.Time `json:"updated_at"` // Joined fields @@ -880,7 +880,7 @@ type EditSnapshot struct { StartTime *time.Time `json:"start_time"` EndTime *time.Time `json:"end_time"` Services []EditServiceDetail `json:"services"` - Notes *string `json:"notes"` + Notes *string `json:"notes" validate:"omitempty,max=1000000"` } type EditUserSummary struct { @@ -895,7 +895,7 @@ type EnrichedEditRequest struct { BookingID string `json:"booking_id"` RequestedBy string `json:"requested_by"` RequestedAt time.Time `json:"requested_at"` - Notes *string `json:"notes"` + Notes *string `json:"notes" validate:"omitempty,max=1000000"` Original *EditSnapshot `json:"original"` Proposed *EditSnapshot `json:"proposed"` User *EditUserSummary `json:"user,omitempty"` @@ -1170,7 +1170,7 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) { var req struct { NewStartTime *time.Time `json:"new_start_time,omitempty"` NewServices []string `json:"new_services"` - Notes *string `json:"notes,omitempty"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) diff --git a/backend/handlers/bookings/reserve.go b/backend/handlers/bookings/reserve.go index 657e83e..80dc477 100644 --- a/backend/handlers/bookings/reserve.go +++ b/backend/handlers/bookings/reserve.go @@ -69,7 +69,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(authHeader, "Bearer ") { tokenString := strings.TrimPrefix(authHeader, "Bearer ") var err error - userID, _, err = auth.VerifyToken(tokenString, r.Context()) + userID, _, _, err = auth.VerifyToken(tokenString, r.Context()) if err != nil { // Invalid token - treat as unauthenticated userID = "" diff --git a/backend/handlers/portfolio/images.go b/backend/handlers/portfolio/images.go index bf4eb1f..906d5d0 100644 --- a/backend/handlers/portfolio/images.go +++ b/backend/handlers/portfolio/images.go @@ -733,11 +733,27 @@ func DeleteImage(w http.ResponseWriter, r *http.Request) { } func extractKey(url string) string { - parts := strings.Split(url, "/") - if len(parts) > 0 { - return parts[len(parts)-1] + // URL format: https://endpoint/bucket/portfolio/1234567890.jpg + // Need to return: portfolio/1234567890.jpg + // Find the bucket segment: skip past scheme://endpoint/ + idx := strings.Index(url, "://") + if idx == -1 { + return url } - return url + rest := url[idx+3:] // skip "://" + // Now rest = "endpoint/bucket/portfolio/1234567890.jpg" + // Skip first path segment (endpoint) + slashIdx := strings.Index(rest, "/") + if slashIdx == -1 { + return rest + } + rest = rest[slashIdx+1:] // "bucket/portfolio/1234567890.jpg" + // Skip second path segment (bucket) + slashIdx = strings.Index(rest, "/") + if slashIdx == -1 { + return rest + } + return rest[slashIdx+1:] // "portfolio/1234567890.jpg" } func GetImage(w http.ResponseWriter, r *http.Request) { diff --git a/backend/handlers/portfolio/images_test.go b/backend/handlers/portfolio/images_test.go index 8116016..7c73770 100644 --- a/backend/handlers/portfolio/images_test.go +++ b/backend/handlers/portfolio/images_test.go @@ -559,6 +559,65 @@ func TestPortfolio_ProcessImage_EXIFStripped(t *testing.T) { } } +// ============================================================================= +// extractKey Tests +// ============================================================================= + +// TestExtractKey_FullPath tests extractKey with a full S3/R2 URL, verifying +// it extracts the key after bucket: "portfolio/1234567890.jpg". +func TestExtractKey_FullPath(t *testing.T) { + url := "https://endpoint.example.com/crussell/portfolio/1234567890.jpg" + expected := "portfolio/1234567890.jpg" + result := extractKey(url) + if result != expected { + t.Errorf("extractKey(%q) = %q, want %q", url, result, expected) + } +} + +// TestExtractKey_NestedPath tests extractKey with a nested path (thumbnail +// subdirectory), verifying it extracts the full key including subdirectories. +func TestExtractKey_NestedPath(t *testing.T) { + url := "https://endpoint.example.com/crussell/portfolio/thumbs/1234567890.jpg" + expected := "portfolio/thumbs/1234567890.jpg" + result := extractKey(url) + if result != expected { + t.Errorf("extractKey(%q) = %q, want %q", url, result, expected) + } +} + +// TestExtractKey_NoScheme tests extractKey with a URL missing the scheme, +// which exercises the fallback path (no "://" found → returns url as-is). +func TestExtractKey_NoScheme(t *testing.T) { + url := "endpoint.example.com/crussell/portfolio/1234567890.jpg" + expected := "endpoint.example.com/crussell/portfolio/1234567890.jpg" + result := extractKey(url) + if result != expected { + t.Errorf("extractKey(%q) = %q, want %q", url, result, expected) + } +} + +// TestExtractKey_PlainFilename tests extractKey with just a filename (no +// URL structure at all), verifying it returns the input unchanged. +func TestExtractKey_PlainFilename(t *testing.T) { + url := "1234567890.jpg" + expected := "1234567890.jpg" + result := extractKey(url) + if result != expected { + t.Errorf("extractKey(%q) = %q, want %q", url, result, expected) + } +} + +// TestExtractKey_EmptyString tests extractKey with an empty string, +// verifying it returns an empty string. +func TestExtractKey_EmptyString(t *testing.T) { + url := "" + expected := "" + result := extractKey(url) + if result != expected { + t.Errorf("extractKey(%q) = %q, want %q", url, result, expected) + } +} + // createJpegWithExifMarker creates a JPEG with an APP1 EXIF marker inserted after the SOI marker func createJpegWithExifMarker(jpegData []byte) []byte { if len(jpegData) < 2 || jpegData[0] != 0xFF || jpegData[1] != 0xD8 { diff --git a/backend/handlers/services/services.go b/backend/handlers/services/services.go index 2c5b1f9..f49774f 100644 --- a/backend/handlers/services/services.go +++ b/backend/handlers/services/services.go @@ -233,7 +233,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(authHeader, "Bearer ") { tokenString := strings.TrimPrefix(authHeader, "Bearer ") var err error - userID, role, err = auth.VerifyToken(tokenString, r.Context()) + userID, role, _, err = auth.VerifyToken(tokenString, r.Context()) if err != nil { // Invalid token - treat as unauthenticated userID = "" diff --git a/backend/handlers/today/today.go b/backend/handlers/today/today.go index 578a081..6f0df0e 100644 --- a/backend/handlers/today/today.go +++ b/backend/handlers/today/today.go @@ -29,7 +29,7 @@ type AppointmentInfo struct { ID string `json:"id"` StartTime time.Time `json:"start_time"` Status string `json:"status"` - Notes *string `json:"notes,omitempty"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` User *UserInfo `json:"user,omitempty"` Services []ServiceInfo `json:"services"` DurationMinutes int `json:"duration_minutes"` diff --git a/backend/handlers/user/profile.go b/backend/handlers/user/profile.go index 7937f8b..6ce36ec 100644 --- a/backend/handlers/user/profile.go +++ b/backend/handlers/user/profile.go @@ -75,7 +75,7 @@ type AdminUserDetail struct { LastLoginAt *string `json:"lastLoginAt,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` - Notes *string `json:"notes,omitempty"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` // GDPR consent fields PrivacyPolicyConsent bool `json:"privacyPolicyConsent"` diff --git a/backend/main.go b/backend/main.go index 62136e0..0ef1b47 100644 --- a/backend/main.go +++ b/backend/main.go @@ -54,8 +54,9 @@ func limitBody(limit int64) func(http.Handler) http.Handler { } const ( - defaultBodyLimit int64 = 1 * 1024 * 1024 // 1MB - uploadBodyLimit int64 = 15 * 1024 * 1024 // 15MB + defaultBodyLimit int64 = 1 * 1024 * 1024 // 1MB + uploadBodyLimit int64 = 15 * 1024 * 1024 // 15MB + portfolioBodyLimit int64 = 20 * 1024 * 1024 // 20MB ) func initDB() { @@ -164,6 +165,9 @@ func main() { // Login: Has its own internal rate limiting r.With(mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/login", authHandlers.LoginHandler) + // Logout: requires valid token + r.With(mw.RequireAuth).Post("/logout", authHandlers.LogoutHandler) + // Email verification r.With(mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/verify/generate", authHandlers.GenerateVerificationCodeHandler) r.With(mw.RateLimit(20, time.Minute), limitBody(defaultBodyLimit)).Post("/verify/check", authHandlers.VerifyCodeHandler) @@ -185,7 +189,7 @@ func main() { r.Use(mw.RequireAuth) r.Use(mw.RequireAdmin) r.Use(mw.RateLimit(60, time.Minute)) - r.With(limitBody(uploadBodyLimit)).Post("/images", portfolio.UploadImage) + r.With(limitBody(portfolioBodyLimit)).Post("/images", portfolio.UploadImage) r.Delete("/images/{id}", portfolio.DeleteImage) }) }) diff --git a/backend/mw/auth.go b/backend/mw/auth.go index bc1d214..3540c6a 100644 --- a/backend/mw/auth.go +++ b/backend/mw/auth.go @@ -13,6 +13,7 @@ type contextKey string const ( UserIDKey contextKey = "user_id" UserRoleKey contextKey = "user_role" + JTIKey contextKey = "jti" ) // RequireAuth middleware - validates JWT and adds user info to context @@ -26,15 +27,16 @@ func RequireAuth(next http.Handler) http.Handler { tokenString := strings.TrimPrefix(authHeader, "Bearer ") - userID, role, err := auth.VerifyToken(tokenString, r.Context()) + userID, role, jti, err := auth.VerifyToken(tokenString, r.Context()) if err != nil { http.Error(w, "invalid token", http.StatusUnauthorized) return } - // Add user info to context + // Add user info and JTI to context ctx := context.WithValue(r.Context(), UserIDKey, userID) ctx = context.WithValue(ctx, UserRoleKey, role) + ctx = context.WithValue(ctx, JTIKey, jti) next.ServeHTTP(w, r.WithContext(ctx)) }) @@ -47,10 +49,11 @@ func OptionalAuth(next http.Handler) http.Handler { if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") { tokenString := strings.TrimPrefix(authHeader, "Bearer ") - userID, role, err := auth.VerifyToken(tokenString, r.Context()) + userID, role, jti, err := auth.VerifyToken(tokenString, r.Context()) if err == nil { ctx := context.WithValue(r.Context(), UserIDKey, userID) ctx = context.WithValue(ctx, UserRoleKey, role) + ctx = context.WithValue(ctx, JTIKey, jti) next.ServeHTTP(w, r.WithContext(ctx)) return } @@ -109,3 +112,8 @@ func GetUserRole(ctx context.Context) (string, bool) { role, ok := ctx.Value(UserRoleKey).(string) return role, ok } + +func GetJTI(ctx context.Context) (string, bool) { + jti, ok := ctx.Value(JTIKey).(string) + return jti, ok +} diff --git a/backend/testutils/jwt/jwt.go b/backend/testutils/jwt/jwt.go index dbd1a26..a0a8e29 100644 --- a/backend/testutils/jwt/jwt.go +++ b/backend/testutils/jwt/jwt.go @@ -37,7 +37,7 @@ func EnsureInitialized() { func GenerateTestToken(userID, role string) string { EnsureInitialized() - token, err := auth.GenerateToken(userID, role) + token, _, err := auth.GenerateToken(userID, role) if err != nil { panic("failed to generate test token: " + err.Error()) } diff --git a/frontend/src/lib/components/account/EditRequestModal.svelte b/frontend/src/lib/components/account/EditRequestModal.svelte index 85f6334..8b50c60 100644 --- a/frontend/src/lib/components/account/EditRequestModal.svelte +++ b/frontend/src/lib/components/account/EditRequestModal.svelte @@ -18,6 +18,7 @@ import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left'; import ArrowRightIcon from '@lucide/svelte/icons/arrow-right'; + import CharCounter from '$lib/components/ui/CharCounter.svelte'; interface Props { open: boolean; @@ -893,6 +894,7 @@ placeholder="Tell us why you need to make changes" rows={2} /> + {:else if editMode === 'services'} @@ -1018,6 +1020,7 @@ placeholder="Any special requests or notes for your appointment" rows={2} /> + {:else if editMode === 'both-services'} @@ -1128,6 +1131,7 @@ placeholder="Any special requests or notes for your appointment" rows={2} /> + {/if} {/if} diff --git a/frontend/src/lib/components/admin/ApprovalModal.svelte b/frontend/src/lib/components/admin/ApprovalModal.svelte index d076835..df7fab5 100644 --- a/frontend/src/lib/components/admin/ApprovalModal.svelte +++ b/frontend/src/lib/components/admin/ApprovalModal.svelte @@ -7,6 +7,7 @@ import { Input } from '$lib/components/ui/input'; import { Textarea } from '$lib/components/ui/textarea'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; + import CharCounter from '$lib/components/ui/CharCounter.svelte'; interface Props { open: boolean; @@ -492,6 +493,7 @@ rows={3} class="w-full" /> + {#if notes.trim() !== (booking.notes || '')}
✓ Notes will be saved (different from original) diff --git a/frontend/src/lib/components/admin/BookingCreateModal.svelte b/frontend/src/lib/components/admin/BookingCreateModal.svelte index 5f26dbc..6fa37b4 100644 --- a/frontend/src/lib/components/admin/BookingCreateModal.svelte +++ b/frontend/src/lib/components/admin/BookingCreateModal.svelte @@ -13,6 +13,7 @@ import { Label } from '$lib/components/ui/label'; import { Separator } from '$lib/components/ui/separator'; import { Skeleton } from '$lib/components/ui/skeleton'; + import CharCounter from '$lib/components/ui/CharCounter.svelte'; // Booking Components import BookingActions from '$lib/components/booking/BookingActions.svelte'; @@ -1112,6 +1113,7 @@ bind:value={notes} placeholder="Any special requirements, preferences, or notes about this booking..." > +
diff --git a/frontend/src/lib/components/admin/EditBookingModal.svelte b/frontend/src/lib/components/admin/EditBookingModal.svelte index 13087db..6621e4f 100644 --- a/frontend/src/lib/components/admin/EditBookingModal.svelte +++ b/frontend/src/lib/components/admin/EditBookingModal.svelte @@ -8,6 +8,7 @@ import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; import { Textarea } from '$lib/components/ui/textarea'; + import CharCounter from '$lib/components/ui/CharCounter.svelte'; import type { Booking, BookingService, Service } from '$lib/types/booking'; interface Props { @@ -614,6 +615,7 @@ rows={3} class="w-full" /> + {#if notes.trim() !== (booking.notes || '')}
Notes will be saved
{/if} diff --git a/frontend/src/lib/components/admin/WalkInCreateModal.svelte b/frontend/src/lib/components/admin/WalkInCreateModal.svelte index 785a85a..d844206 100644 --- a/frontend/src/lib/components/admin/WalkInCreateModal.svelte +++ b/frontend/src/lib/components/admin/WalkInCreateModal.svelte @@ -11,6 +11,7 @@ import { Label } from '$lib/components/ui/label'; import { Separator } from '$lib/components/ui/separator'; import { Skeleton } from '$lib/components/ui/skeleton'; + import CharCounter from '$lib/components/ui/CharCounter.svelte'; // Booking Components import BookingActions from '$lib/components/booking/BookingActions.svelte'; @@ -811,6 +812,7 @@ bind:value={notes} placeholder="Any special requirements, preferences, or notes about this booking..." > + diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index 61b41a6..8a9bbdc 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -4,6 +4,7 @@ import { Input } from '$lib/components/ui/input/index.js'; import { Label } from '$lib/components/ui/label/index.js'; import { Textarea } from '$lib/components/ui/textarea/index.js'; + import CharCounter from '$lib/components/ui/CharCounter.svelte'; import { Separator } from '$lib/components/ui/separator/index.js'; import { Checkbox } from '$lib/components/ui/checkbox/index.js'; // INTENTIONAL: We use the browser's local timezone (getLocalTimeZone) because Crussell is a UK-only @@ -1659,6 +1660,7 @@ placeholder="Any allergies, preferences, or special requirements..." rows={3} /> +
diff --git a/frontend/src/lib/components/ui/CharCounter.svelte b/frontend/src/lib/components/ui/CharCounter.svelte new file mode 100644 index 0000000..818a6d5 --- /dev/null +++ b/frontend/src/lib/components/ui/CharCounter.svelte @@ -0,0 +1,27 @@ + + +{#if shouldShow} +

+ {remaining.toLocaleString()} characters remaining ({graphemeCount.toLocaleString()} / {maxChars.toLocaleString()}) +

+{/if} diff --git a/frontend/src/lib/stores/auth.svelte.ts b/frontend/src/lib/stores/auth.svelte.ts index 405d7e4..f62c3b5 100644 --- a/frontend/src/lib/stores/auth.svelte.ts +++ b/frontend/src/lib/stores/auth.svelte.ts @@ -147,7 +147,17 @@ class AuthStore { } // inside AuthStore - logout = () => { + logout = async () => { + if (this.token) { + try { + await fetch('/api/logout', { + method: 'POST', + headers: { Authorization: `Bearer ${this.token}` } + }); + } catch (e) { + // Ignore network errors - still clear local state + } + } this.clearAuth(); goto('/', { invalidateAll: true }); }; diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index 4de5edb..4b05d97 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -245,10 +245,16 @@ let zoom = $state(1); let previewUrl = $state(''); + const PROFILE_PIC_MAX_SIZE = 15 * 1024 * 1024; // 15MB + function handleFileSelect(e: Event) { const input = e.target as HTMLInputElement; const file = input.files?.[0]; if (file) { + if (file.size > PROFILE_PIC_MAX_SIZE) { + toast.error('Profile picture must be under 15MB'); + return; + } cropImageUrl = URL.createObjectURL(file); cropDialogOpen = true; } diff --git a/obsidian/Crussell/Admin Manual.md b/obsidian/Crussell/Admin Manual.md index 14268ad..6c042ae 100644 --- a/obsidian/Crussell/Admin Manual.md +++ b/obsidian/Crussell/Admin Manual.md @@ -477,11 +477,11 @@ The window shows the full booking details so you can review them before making y ## Notifications -The notifications page keeps you informed about everything happening with your bookings and customers. You'll find a bell icon in the top-right corner of the navigation bar — if there's a red dot on it, you have unread notifications. +The notifications page keeps you informed about everything happening with your bookings and customers. You'll find a bell icon in the top-right corner of the navigation bar — if there's a red dot on it, you have unread notifications. On mobile, the unread count also appears on the hamburger menu icon so you can see it at a glance when the menu is collapsed. ### How to Access -Click the **bell icon** in the top-right corner of the website to go to the Notifications page. The bell shows a number indicating how many unread notifications you have. +Click the **bell icon** in the top-right corner of the website (or the **notification badge** on the mobile hamburger menu) to go to the Notifications page. The bell shows a number indicating how many unread notifications you have. ### What You'll See @@ -554,9 +554,10 @@ The portfolio is the salon's gallery of nail art photos that customers can brows 2. Find the image upload section 3. **Drag and drop** an image file, or click to select one from your device 4. A preview of the image appears so you can check it looks right -5. **Add tags** — type keywords that describe the image (for example, "french tip", "red", "summer", "glitter"). As you type, the system suggests tags that have been used before, so you can keep tags consistent -6. A confirmation window appears before the image is uploaded — this is your last chance to double-check -7. **Confirm** — the image is uploaded and appears in the portfolio gallery +5. **File size limit**: Images must be under 20MB. If you select a file larger than 20MB, the upload area gets a red border and shows an error message, and the upload button is disabled until you choose a smaller file +6. **Add tags** — type keywords that describe the image (for example, "french tip", "red", "summer", "glitter"). As you type, the system suggests tags that have been used before, so you can keep tags consistent +7. A confirmation window appears before the image is uploaded — this is your last chance to double-check +8. **Confirm** — the image is uploaded and appears in the portfolio gallery ### Tips for Tagging diff --git a/obsidian/Crussell/Future Work - Gap Backlog.md b/obsidian/Crussell/Future Work - Gap Backlog.md index 44cc74f..f30058f 100644 --- a/obsidian/Crussell/Future Work - Gap Backlog.md +++ b/obsidian/Crussell/Future Work - Gap Backlog.md @@ -1,4 +1,4 @@ -**Last Updated:** May 2026 — Admin schedule page (weekly calendar view), referral code registration, BookingFlow welcome step for guests, patch_test_duration_hours on services, shared format utilities, created_by_name on bookings, admin login redirect, 446/449 tests passing (3 skipped) +**Last Updated:** June 2026 — JWT revocation with JTI, portfolio image upload limits (20MB frontend/backend), notes validation (max=1000000), CharCounter component, loginInProgress rate limiting cap, profile picture 15MB limit, formatDateISO utility, NavBar/TodayCalendar/lunch protection refinements **Status:** Living backlog — add to this as gaps are discovered --- diff --git a/obsidian/Crussell/Overview.md b/obsidian/Crussell/Overview.md index 2ab074b..9f24b94 100644 --- a/obsidian/Crussell/Overview.md +++ b/obsidian/Crussell/Overview.md @@ -91,6 +91,7 @@ flowchart TD - Email verification and password reset endpoints (backend ready, frontend not wired) - Guest/disposable accounts for one-off bookings - **Admin login redirect**: admins are redirected to `/today` after login instead of the home page +- **JWT revocation with JTI**: every JWT includes a unique `jti` claim (UUID v4) for revocation tracking. In-memory map of revoked JTIs with 5-minute cleanup ticker. `POST /api/logout` revokes the current token. Refresh handler revokes old JTI before issuing new token ### Booking System - Three booking flows: self-service (customer), walk-in (admin), call-in (admin) @@ -207,7 +208,7 @@ All flows integrate with holiday/exceptional hours and time blockers. All bookin ## Test Coverage -**446/449 tests passing** (3 skipped) across 12+ test packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, cash/gift card payments, enriched edit request workflows, GetBookingsByCreatedRange endpoint, scheduling exceptional hours, and referral code validation. +**446/449 tests passing** (3 skipped) across 12+ test packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, cash/gift card payments, enriched edit request workflows, GetBookingsByCreatedRange endpoint, scheduling exceptional hours, referral code validation, and JWT revocation via JTI logout. | Package | Coverage Area | |---------|--------------| diff --git a/obsidian/Crussell/Technical Manual.md b/obsidian/Crussell/Technical Manual.md index 3162242..58547e5 100644 --- a/obsidian/Crussell/Technical Manual.md +++ b/obsidian/Crussell/Technical Manual.md @@ -63,7 +63,7 @@ Backend (:8080) | Middleware | Purpose | |------------|---------| -| `RequireAuth` | Validates JWT, adds user_id and user_role to context | +| `RequireAuth` | Validates JWT, extracts `jti` claim, adds user_id and user_role to context; rejects revoked JTIs | | `OptionalAuth` | Extracts user info if token present, passes through otherwise | | `RequireAdmin` | Allows admin role only | | `RequireVerified` | Allows verified_email or admin | @@ -178,12 +178,15 @@ src/lib/components/ - `getDayWithOrdinal()` — formats a CalendarDate with ordinal suffix (e.g., "January 15th") - Types: `DayHours`, `DayAvailability` — shared type definitions for working/available hours data +- **`CharCounter` component** (`lib/components/ui/CharCounter.svelte`): Reusable grapheme counter using `Intl.Segmenter` for correct character boundary detection (handles emoji, multi-byte chars). Hidden below 750K graphemes, color-coded above: green (<800K), yellow (800K-950K), red (>950K). Integrated into 6 booking/admin components for notes fields. + - **`lib/utils/format.ts`**: Shared formatting utilities for consistent display across the app - `formatDuration(minutes)` — converts minutes to human-readable string (e.g., 90 → "1h 30m") - `formatDateTime(date)` — formats to "Weekday, Month Day at HH:MM AM/PM" - `formatDate(date)` — formats to "Weekday, Month Day" (no time) - `formatTime(date)` — formats to "HH:MM AM/PM" - `calculateAge(dateOfBirth)` — calculates age in years from DOB string + - `formatDateISO(date)` — formats a Date object to YYYY-MM-DD string for API calls --- @@ -217,7 +220,8 @@ src/lib/components/ | Method | Path | Description | |--------|------|-------------| -| POST | `/api/refresh-token` | Refresh JWT (role-change detection) | +| POST | `/api/logout` | Revoke current JWT token | +| POST | `/api/refresh-token` | Refresh JWT (role-change detection, revokes old JTI) | | GET | `/api/user/profile` | Get current user profile | | PUT | `/api/user/profile` | Update profile | | POST | `/api/user/profile-picture` | Upload profile picture (cropper) | @@ -653,6 +657,8 @@ type EnrichedEditRequest struct { **Frontend:** `buildLunchProtection()` in `lib/utils/timeSlots.ts` consolidates lunch protection logic that was previously duplicated across BookingFlow, BookingCreateModal, and EditRequestModal. All booking flows now use the shared utility. +**`shouldApplyLunchProtection()`:** A new helper that skips lunch protection entirely for short working days (5 hours or less). This prevents false lunch-break warnings on half-days or days with abbreviated hours. + --- ### Referral Code System @@ -803,7 +809,7 @@ go test -tags "test,dev" -v -p 1 -count=2 ./... # Run twice for flaky detection ### Test Coverage -**446/449 tests passing** (3 skipped) across 12+ packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, and cash/gift card payments, GetBookingsByCreatedRange endpoint, scheduling exceptional hours validation, and referral code registration. +**446/449 tests passing** (3 skipped) across 12+ packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, and cash/gift card payments, GetBookingsByCreatedRange endpoint, scheduling exceptional hours validation, referral code registration, and JWT revocation via JTI logout. - `handlers/auth` — Authentication (login, register, referral code validation, refresh, verification) - `handlers/bookings` — User booking flow, guest bookings, reservations, edit requests, discounts, closing hours validation, active booking limits, GetBookingsByCreatedRange - `handlers/payments` — Square payments (terminal, online, refunds, tips, saved cards) @@ -834,6 +840,7 @@ go test -tags "test,dev" -v -p 1 -count=2 ./... # Run twice for flaky detection - **Buckets**: `crussell` (portfolio), `crussell-profile-pics` (profile pictures) - **Image formats**: AVIF full-size (0.72 quality, 1500px max), WebP thumbnails (250x250) - **Security**: EXIF/GPS metadata stripped on upload via `imaging` library +- **extractKey fix**: Portfolio image delete now correctly extracts the full S3 key path (e.g., `portfolio/1234567890.jpg`) from URLs instead of just the filename. This prevents orphaned files in storage ### Square Payment Integration @@ -879,6 +886,10 @@ go test -tags "test,dev" -v -p 1 -count=2 ./... # Run twice for flaky detection - **Secret**: `JWT_SECRET_KEY` environment variable (required, checked in `init()`) - **Refresh**: Auto-refresh via `POST /api/refresh-token` - **Role-change detection**: Forces re-login if role changed +- **JWT ID (JTI)**: Every JWT includes a unique `jti` claim (UUID v4) for revocation tracking +- **Revocation**: In-memory `map[string]time.Time` tracks revoked JTIs. A background ticker runs every 5 minutes to purge expired entries +- **Logout**: `POST /api/logout` revokes the current JWT token by adding its JTI to the revocation map +- **Refresh revocation**: When a new token is issued via refresh, the old JTI is added to the revocation map ### Rate Limiting @@ -894,6 +905,12 @@ go test -tags "test,dev" -v -p 1 -count=2 ./... # Run twice for flaky detection **IP extraction**: CF-Connecting-IP → X-Real-IP → X-Forwarded-For → RemoteAddr +**loginInProgress rate limiting:** +- `loginInProgress` is a `map[string]time.Time` tracking in-progress login attempts +- 30-second staleness check: entries older than 30s are treated as expired +- 20-entry size cap: returns 429 "too many login attempts" when full +- A ticker goroutine periodically cleans up stuck/expired entries + ### Security Headers - `X-Content-Type-Options: nosniff` @@ -910,11 +927,14 @@ go test -tags "test,dev" -v -p 1 -count=2 ./... # Run twice for flaky detection - **Age**: Must be 16+ years - **Services**: name (100), price (>0), duration (1-480), patch test (0-168), age (0-100) - **Portfolio**: tags/filters (256 char max), filter category validation, image ID pattern security +- **Notes**: all `Notes *string` fields across booking structs (13 fields across 4 files) have `validate:"omitempty,max=1000000"` tag ### Image Security - EXIF/GPS metadata stripped on all uploads via `imaging` library - Profile pictures stored in separate bucket from portfolio images +- **Profile picture upload limit**: 15MB client-side check before crop dialog in account page +- **Portfolio image upload limit**: 20MB backend limit via `portfolioBodyLimit` constant applied to `/images` route, distinct from `uploadBodyLimit` (15MB) for profile pictures. Frontend also enforces 20MB with visual feedback (red borders, error text) and disables upload button for oversized files ---