This MR fixes critical authentication issues that prevented login on localhost and improves the developer experience with consolidated rebuild scripts and a working help modal keyboard shortcut. (#5)
All checks were successful
CI / skip-ci-check (push) Successful in 1m25s
CI / lint-and-type-check (push) Successful in 1m50s
CI / test (push) Successful in 1m54s
CI / build (push) Successful in 1m54s
CI / secret-scanning (push) Successful in 1m26s
CI / dependency-scan (push) Successful in 1m31s
CI / sast-scan (push) Successful in 2m34s
CI / workflow-summary (push) Successful in 1m23s

# Fix authentication issues and improve developer experience

## Summary

This MR fixes critical authentication issues that prevented login on localhost and improves the developer experience with consolidated rebuild scripts and a working help modal keyboard shortcut.

## Problems Fixed

### 1. Authentication Issues
- **UntrustedHost Error**: NextAuth v5 was rejecting localhost requests with "UntrustedHost: Host must be trusted" error
- **Cookie Prefix Errors**: Cookies were being set with `__Host-` and `__Secure-` prefixes on HTTP (localhost), causing browser rejection
- **MissingCSRF Error**: CSRF token cookies were not being set correctly due to cookie configuration issues

### 2. Help Modal Keyboard Shortcut
- **Shift+? not working**: The help modal keyboard shortcut was not detecting the question mark key correctly

### 3. Developer Experience
- **Multiple rebuild scripts**: Had several overlapping rebuild scripts that were confusing
- **Unused code**: Removed unused `useSecureCookies` variable and misleading comments

## Changes Made

### Authentication Fixes (`lib/auth.ts`)
- Set `trustHost: true` to fix UntrustedHost error (required for NextAuth v5)
- Added explicit cookie configuration for HTTP (localhost) to prevent prefix errors:
  - Cookies use `secure: false` for HTTP
  - Cookie names without prefixes for HTTP
  - Let Auth.js defaults handle HTTPS (with prefixes and Secure flag)
- Removed unused `useSecureCookies` variable
- Simplified debug logging

### Help Modal Fix (`components/HelpModal.tsx`)
- Fixed keyboard shortcut detection to properly handle Shift+? (Shift+/)
- Updated help text to show correct shortcut (Shift+? instead of Ctrl+?)

### Developer Scripts
- **Consolidated rebuild scripts**: Merged `CLEAN_REBUILD.sh`, `FIX_AND_RESTART.sh`, and `start-server.sh` into single `rebuild.sh`
- **Added REBUILD.md**: Documentation for rebuild process
- Removed redundant script files

### Code Cleanup
- Removed unused `useSecureCookies` variable from `lib/auth.ts`
- Removed misleading comment from `app/api/auth/[...nextauth]/route.ts`
- Cleaned up verbose debug logging

## Technical Details

### Cookie Configuration
The fix works by explicitly configuring cookies for HTTP environments:
- **HTTP (localhost)**: Cookies without prefixes, `secure: false`
- **HTTPS (production)**: Let Auth.js defaults handle (prefixes + Secure flag)

This prevents NextAuth v5 from auto-detecting HTTPS from proxy headers and incorrectly adding cookie prefixes.

### Keyboard Shortcut
The question mark key requires Shift+/ on most keyboards. The fix now properly detects:
- `event.shiftKey && event.key === "/"`
- `event.key === "?"` (fallback)
- `event.code === "Slash" && event.shiftKey` (additional fallback)

## Testing

-  Login works on localhost (http://localhost:3000)
-  No cookie prefix errors in browser console
-  No UntrustedHost errors in server logs
-  Help modal opens/closes with Shift+?
-  Rebuild script works in both dev and prod modes

## Files Changed

### Modified
- `lib/auth.ts` - Authentication configuration fixes
- `components/HelpModal.tsx` - Keyboard shortcut fix
- `app/api/auth/[...nextauth]/route.ts` - Removed misleading comment

### Added
- `rebuild.sh` - Consolidated rebuild script
- `REBUILD.md` - Rebuild documentation

## Migration Notes

No database migrations or environment variable changes required. The fix works with existing configuration.

## Related Issues

Fixes authentication issues preventing local development and testing.

Reviewed-on: #5
This commit is contained in:
ilia 2026-01-05 19:42:46 -05:00
parent dfc2ee978d
commit df865dca41
34 changed files with 2006 additions and 201 deletions

3
.gitignore vendored
View File

@ -50,3 +50,6 @@ next-env.d.ts
# Test coverage
/coverage
/.nyc_output
# Application logs
*.log

View File

@ -233,7 +233,7 @@ model Guess {
**Flow:**
1. User navigates to `/upload`
2. Uploads photo file or enters photo URL and answer name
3. Form submits to `POST /api/photos/upload` (file upload) or `POST /api/photos` (URL)
3. Form submits to `POST /api/photos/upload` (supports both file and URL uploads)
4. API route:
- Verifies session
- For file uploads:
@ -253,8 +253,8 @@ model Guess {
5. User redirected to photo detail page
**API Routes:**
- `app/api/photos/upload/route.ts` - File upload endpoint
- `app/api/photos/route.ts` - URL upload endpoint (legacy)
- `app/api/photos/upload/route.ts` - Single photo upload endpoint (supports both file and URL uploads)
- `app/api/photos/upload-multiple/route.ts` - Multiple photo upload endpoint
- `app/api/uploads/[filename]/route.ts` - Serves uploaded files
**File Storage:**

335
IMPROVEMENTS.md Normal file
View File

@ -0,0 +1,335 @@
# MirrorMatch - Improvement Ideas & Future Enhancements
This document contains ideas, suggestions, and potential enhancements to make MirrorMatch better, faster, and more professional.
## 🚀 Performance Optimizations
### Database & Query Optimization
- [ ] **Add database indexes** - Create indexes on frequently queried fields (`Photo.createdAt`, `User.points`) to speed up queries
- **Why:** As your database grows, queries without indexes become exponentially slower. Indexes can make queries 10-100x faster, especially for leaderboard and photo listing queries that run frequently.
- [ ] **Implement pagination** - Add pagination to photos list to handle large datasets efficiently
- **Why:** Loading all photos at once becomes slow and memory-intensive as you scale. Pagination improves load times, reduces server load, and provides better UX by showing manageable chunks of data.
- [ ] **Database connection pooling** - Configure connection pooling to optimize database connections
- **Why:** Without pooling, each request creates a new database connection, which is expensive. Connection pooling reuses connections, reducing latency and allowing your app to handle more concurrent users.
- [ ] **Cache leaderboard data** - Use Redis or in-memory cache with TTL (1-5 minutes) to reduce database load
- **Why:** The leaderboard is queried frequently but changes infrequently. Caching it reduces database load by 90%+ and makes the page load instantly, improving user experience significantly.
- [ ] **Lazy loading for photos** - Load images as user scrolls to improve initial page load
- **Why:** Loading all images upfront is slow and wastes bandwidth. Lazy loading makes pages load 3-5x faster and reduces server costs, especially important for mobile users.
- [ ] **Query optimization** - Use `select` to fetch only needed fields, reducing data transfer
- **Why:** Fetching entire records when you only need a few fields wastes bandwidth and memory. This simple change can reduce data transfer by 50-80% and speed up queries.
### Frontend Performance
- [ ] **Image optimization** - Use Next.js Image component with proper sizing and automatic format optimization
- **Why:** Unoptimized images can be 5-10x larger than needed. Next.js Image automatically serves WebP/AVIF formats and resizes images, reducing bandwidth by 60-80% and improving page load times dramatically.
- [ ] **Image lazy loading** - Implement lazy loading for photo thumbnails to improve page load times
- **Why:** Loading all thumbnails at once slows initial page load. Lazy loading defers off-screen images, making pages load 2-3x faster and improving Core Web Vitals scores.
- [ ] **Virtual scrolling** - Use virtual scrolling for large photo lists to maintain performance
- **Why:** Rendering hundreds of DOM elements causes lag. Virtual scrolling only renders visible items, keeping the UI smooth even with thousands of photos.
- [ ] **Code splitting** - Implement route-based code splitting to reduce initial bundle size
- **Why:** Large JavaScript bundles slow initial page load. Code splitting loads only what's needed per route, reducing initial bundle by 40-60% and improving Time to Interactive.
- [ ] **React.memo() optimization** - Wrap expensive components with React.memo() to prevent unnecessary re-renders
- **Why:** Unnecessary re-renders waste CPU and cause UI lag. Memoization prevents re-renders when props haven't changed, making the UI more responsive, especially on lower-end devices.
- [ ] **Bundle size optimization** - Analyze and optimize bundle size using webpack-bundle-analyzer
- **Why:** Large bundles slow page loads and hurt SEO. Identifying and removing unused code can reduce bundle size by 20-40%, improving load times and user experience.
- [ ] **Progressive image loading** - Show blur placeholder while images load, then transition to full image
- **Why:** Blank spaces while images load feel slow. Progressive loading provides immediate visual feedback, making the app feel faster and more polished, improving perceived performance.
### API & Backend
- [ ] **Rate limiting** - Add rate limiting to prevent abuse and protect API endpoints
- **Why:** Without rate limiting, malicious users can overwhelm your server or abuse features. Rate limiting protects against DDoS, prevents spam, and ensures fair resource usage for all users.
- [ ] **Request caching** - Implement caching for static data and frequently accessed endpoints
- **Why:** Many API calls return the same data repeatedly. Caching reduces database queries by 70-90%, lowers server costs, and makes responses 10-100x faster.
- [ ] **Compression middleware** - Add gzip/brotli compression to reduce response sizes
- **Why:** Text responses (JSON, HTML) compress by 70-90%. Compression reduces bandwidth costs, speeds up transfers (especially on mobile), and improves user experience with minimal effort.
- [ ] **Email queue system** - Use Bull/BullMQ to queue email sending and prevent blocking
- **Why:** Sending emails synchronously blocks requests and can cause timeouts. A queue system makes email sending non-blocking, improves response times, and provides retry logic for failed emails.
- [ ] **Batch photo uploads** - Optimize bulk photo upload operations for better performance
- **Why:** Uploading photos one-by-one is slow and inefficient. Batch uploads reduce overhead, improve upload speeds by 2-3x, and provide better progress feedback to users.
- [ ] **CDN integration** - Add CDN for static assets and uploaded images to reduce server load
- **Why:** Serving files from your server uses bandwidth and slows responses. A CDN serves files from locations closer to users, reducing latency by 50-80% and offloading server load.
## 🎨 UI/UX Enhancements
### User Interface
- [ ] **Dark mode support** - Add dark mode toggle with user preference persistence
- **Why:** Many users prefer dark mode for reduced eye strain, especially in low-light. It's become an expected feature and can increase user satisfaction and retention.
- [ ] **Mobile responsiveness** - Improve mobile experience with better touch targets and swipe gestures
- **Why:** A significant portion of users access from mobile. Poor mobile UX leads to frustration and abandonment. Better touch targets and gestures make the app feel native and professional.
- [ ] **Loading skeletons** - Replace blank screens with loading skeletons for better perceived performance
- **Why:** Blank screens make the app feel broken or slow. Skeletons show structure immediately, making wait times feel shorter and the app more responsive, improving user confidence.
- [ ] **Toast notifications** - Implement toast notifications for user feedback (success, error, info)
- **Why:** Users need clear feedback for their actions. Toast notifications are non-intrusive, provide immediate feedback, and improve UX by clearly communicating success/error states.
- [ ] **Smooth page transitions** - Add smooth transitions between pages for better user experience
- **Why:** Abrupt page changes feel jarring. Smooth transitions create a polished, professional feel and make navigation feel faster and more cohesive.
- [ ] **Inline form validation** - Show validation errors inline as user types for better feedback
- **Why:** Waiting until form submission to show errors is frustrating. Inline validation provides immediate feedback, reduces errors, and improves form completion rates.
- [ ] **Keyboard navigation** - Add full keyboard navigation support for accessibility
- **Why:** Keyboard navigation is essential for accessibility (WCAG compliance) and power users. It makes your app usable for people with disabilities and improves efficiency for all users.
- [ ] **Drag-and-drop uploads** - Allow users to drag and drop photos for easier uploads
- **Why:** Drag-and-drop is faster and more intuitive than clicking "Choose File". It's a modern UX pattern users expect and can increase upload engagement.
- [ ] **Empty states** - Create better empty states for when there are no photos, guesses, etc.
- **Why:** Empty screens confuse users. Well-designed empty states guide users on what to do next, reduce confusion, and make the app feel more polished and helpful.
- [ ] **Animations and micro-interactions** - Add subtle animations to improve user engagement
- **Why:** Subtle animations provide visual feedback, make interactions feel responsive, and create a premium, polished feel that increases user satisfaction and engagement.
- [ ] **Infinite scroll** - Implement infinite scroll for photos list instead of pagination
- **Why:** Infinite scroll feels more modern and seamless, especially on mobile. It reduces friction and can increase engagement by making it easier to browse through photos.
- [ ] **Photo filters and search** - Add filtering and search functionality to find photos easily
- **Why:** As the photo library grows, finding specific photos becomes difficult. Filters and search help users quickly find what they're looking for, improving usability and engagement.
### User Experience
- [ ] **Onboarding tour** - Create interactive tour for new users explaining key features
- **Why:** New users often don't understand how to use the app. An onboarding tour reduces confusion, increases feature discovery, and improves user retention by helping users get value quickly.
- [ ] **Remember me option** - Add "Remember me" checkbox to login for longer session duration
- **Why:** Frequent logouts frustrate users. A "Remember me" option improves convenience, reduces friction, and increases user satisfaction, especially for regular users.
- [ ] **Guess history display** - Show user's guess history for each photo (correct/wrong attempts)
- **Why:** Users want to see their past attempts. Showing guess history provides context, helps users learn, and adds transparency that increases trust and engagement.
- [ ] **Already guessed indicator** - Display clear indicator when user has already guessed a photo
- **Why:** Without indicators, users waste time trying to guess photos they've already attempted. Clear indicators prevent confusion and improve efficiency.
- [ ] **Photo tags system** - Allow users to tag photos for better organization and filtering
- **Why:** Tags help organize and categorize photos, making them easier to find and filter. This improves discoverability and allows for better content organization as the library grows.
- [ ] **Photo descriptions/clues** - Add optional descriptions or clues to photos for hints
- **Why:** Some photos are too difficult without context. Optional clues make the game more accessible, increase engagement, and allow for more creative photo challenges.
- [ ] **Confirmation dialogs** - Add confirmation dialogs for destructive actions (delete, etc.)
- **Why:** Accidental deletions are frustrating and can't be undone. Confirmation dialogs prevent mistakes, protect user data, and reduce support requests from accidental actions.
- [ ] **Photo filtering** - Add filters to show: unguessed photos, correct guesses, wrong guesses, and photos by specific users
- **Why:** Users want to focus on specific subsets of photos. Filtering helps users find unguessed photos to play, review their performance, or follow specific uploaders, significantly improving usability.
## 🔒 Security Improvements
- [ ] **CSRF protection** - Implement CSRF tokens for all state-changing operations
- **Why:** CSRF attacks can trick users into performing unwanted actions. CSRF protection is a security best practice that prevents unauthorized state changes and protects user accounts.
- [ ] **Input sanitization** - Sanitize all user inputs to prevent XSS attacks
- **Why:** Malicious scripts in user input can steal data or hijack sessions. Input sanitization prevents XSS attacks, protects user data, and is essential for any app accepting user input.
- [ ] **File type validation** - Validate actual file content (not just extension) to prevent malicious uploads
- **Why:** Attackers can rename malicious files with safe extensions. Content validation ensures only legitimate image files are uploaded, preventing security vulnerabilities and server compromise.
- [ ] **Virus scanning** - Add virus scanning for uploaded files before storing
- **Why:** Malicious files can harm your server or other users. Virus scanning protects your infrastructure and users, especially important if files are shared or downloaded by others.
- [ ] **Session timeout** - Implement automatic session timeout for inactive users
- **Why:** Long-lived sessions increase security risk if devices are compromised. Session timeouts reduce risk of unauthorized access and are a security best practice for sensitive applications.
- [ ] **Security headers** - Add security headers (CSP, HSTS, X-Frame-Options, etc.)
- **Why:** Security headers prevent common attacks (clickjacking, XSS, MITM). They're easy to implement, provide significant security benefits, and are recommended by security standards.
- [ ] **Audit logging** - Log all admin actions for security auditing and compliance
- **Why:** Admin actions can have significant impact. Audit logs provide accountability, help detect unauthorized access, and are essential for security compliance and incident investigation.
## 📊 Features & Functionality
### Game Mechanics
- [ ] **Streak tracking** - Track consecutive correct guesses and display current streak
- **Why:** Streaks add excitement and encourage daily engagement. They create a sense of achievement and competition, increasing user retention and making the game more addictive.
- [ ] **Achievements/badges system** - Create achievement system with badges for milestones
- **Why:** Achievements provide goals and recognition. They increase engagement, encourage exploration of features, and create a sense of progression that keeps users coming back.
- [ ] **Hints system** - Allow optional hints for photos (earn fewer points when using hints)
- **Why:** Some photos are too difficult, leading to frustration. Hints make the game more accessible while still rewarding skill, increasing engagement and reducing abandonment.
### Social Features
- [ ] **Photo comments** - Allow users to comment on photos
- **Why:** Comments add social interaction and context. They increase engagement, create community, and make photos more interesting by adding discussion and stories.
- [ ] **Reactions/emojis** - Add emoji reactions to photos for quick feedback
- **Why:** Reactions are quick, low-friction ways to engage. They increase interaction rates, add fun, and provide feedback to uploaders without requiring comments.
- [ ] **User mentions** - Support @username mentions in comments
- **Why:** Mentions notify users and create conversations. They increase engagement, enable discussions, and help build community by connecting users.
- [ ] **Activity feed** - Display recent activity feed (guesses, uploads, achievements)
- **Why:** Activity feeds show what's happening in real-time. They increase engagement, help users discover new content, and create a sense of community and activity.
### Admin Features
- [ ] **User activity logs dashboard** - Create admin dashboard showing user activity logs
- **Why:** Admins need visibility into user behavior. Activity logs help identify issues, understand usage patterns, detect abuse, and make data-driven decisions about the platform.
- [ ] **Analytics dashboard** - Build analytics dashboard showing uploads, guesses, engagement metrics
- **Why:** Data-driven decisions require visibility. Analytics help understand what's working, identify trends, measure growth, and optimize features based on actual usage.
- [ ] **System health monitoring** - Add system health monitoring and status page
- **Why:** Proactive monitoring prevents downtime. Health checks help catch issues before users notice, reduce support requests, and provide transparency about system status.
- [ ] **Backup/export functionality** - Create tools to backup and export data
- **Why:** Data loss is catastrophic. Backups protect against disasters, and export functionality helps with migrations, compliance, and gives users control over their data.
### Photo Management
- [ ] **Photo editing tools** - Add basic editing (crop, rotate, filters) before upload
- **Why:** Users often need to adjust photos before uploading. Built-in editing reduces friction, improves photo quality, and eliminates the need for external editing tools.
- [ ] **Improved duplicate detection** - Enhance duplicate photo detection beyond hash matching
- **Why:** Current hash matching only catches exact duplicates. Better detection (visual similarity) prevents near-duplicates, keeps content fresh, and improves user experience.
- [ ] **Photo tagging system** - Implement tagging system for better organization
- **Why:** Tags enable better organization and discovery. They make it easier to find photos, create themed collections, and improve search functionality as content grows.
- [ ] **Photo search functionality** - Add full-text search for photos by tags, descriptions, answers
- **Why:** As the library grows, finding photos becomes difficult. Search enables quick discovery, improves usability, and is essential for a good user experience at scale.
- [ ] **Photo archiving** - Allow archiving old photos while keeping them accessible
- **Why:** Old photos clutter the main view but shouldn't be deleted. Archiving keeps content organized, maintains history, and improves the browsing experience for active content.
- [ ] **Photo approval workflow** - Add optional admin approval workflow (can be toggled on/off)
- **Why:** Content moderation ensures quality and safety. An approval workflow lets admins control what goes public, prevents inappropriate content, and maintains content standards.
## 🧪 Testing & Quality
- [ ] **Increase test coverage** - Expand test coverage to all critical paths and edge cases
- **Why:** Bugs in production are expensive and damage trust. Comprehensive tests catch issues early, reduce regressions, and give confidence when making changes.
- [ ] **E2E tests** - Add end-to-end tests using Playwright or Cypress
- **Why:** Unit tests don't catch integration issues. E2E tests verify the full user flow works, catch real-world bugs, and prevent breaking changes from reaching users.
- [ ] **Visual regression testing** - Implement visual regression testing to catch UI changes
- **Why:** UI bugs are easy to miss in code review. Visual regression tests catch unintended visual changes, ensure consistency, and save time in QA.
- [ ] **Performance testing** - Add Lighthouse CI for continuous performance monitoring
- **Why:** Performance degrades over time. Automated performance testing catches regressions early, ensures good Core Web Vitals, and maintains fast user experience.
- [ ] **Load testing** - Create load testing scenarios to test system under stress
- **Why:** Systems behave differently under load. Load testing reveals bottlenecks, ensures the app can handle traffic spikes, and prevents crashes during high usage.
- [ ] **API integration tests** - Add comprehensive integration tests for all API routes
- **Why:** API bugs affect all clients. Integration tests verify API contracts, catch breaking changes, and ensure APIs work correctly with the database and auth.
- [ ] **Accessibility testing** - Implement automated accessibility testing (a11y)
- **Why:** Accessibility is a legal requirement and moral obligation. Automated testing catches a11y issues early, ensures WCAG compliance, and makes the app usable for everyone.
- [ ] **Error boundaries** - Add React error boundaries to gracefully handle component errors
- **Why:** One component error shouldn't crash the entire app. Error boundaries isolate failures, show helpful error messages, and keep the rest of the app functional.
- [ ] **Error logging and monitoring** - Create comprehensive error logging and monitoring system
- **Why:** You can't fix what you can't see. Error monitoring catches production bugs, provides context for debugging, and helps prioritize fixes based on impact.
## 🔧 Technical Improvements
### Architecture
- [ ] **Microservices migration** - Consider migrating to microservices if scale requires it
- **Why:** Monoliths become hard to scale and maintain at large scale. Microservices enable independent scaling, faster deployments, and better fault isolation, but only if needed.
- [ ] **Event-driven architecture** - Implement event-driven architecture for notifications
- **Why:** Tight coupling makes systems brittle. Event-driven architecture decouples components, improves scalability, and makes it easier to add new features without modifying existing code.
- [ ] **Message queue** - Add message queue (RabbitMQ, Redis Queue) for async operations
- **Why:** Synchronous operations block requests. Message queues enable async processing, improve response times, provide retry logic, and handle traffic spikes better.
- [ ] **GraphQL API** - Consider GraphQL API for more flexible querying
- **Why:** REST APIs often over-fetch or under-fetch data. GraphQL lets clients request exactly what they need, reduces bandwidth, and simplifies frontend development.
- [ ] **API versioning** - Implement API versioning for backward compatibility
- **Why:** API changes break clients. Versioning allows gradual migration, prevents breaking changes, and enables supporting multiple client versions simultaneously.
- [ ] **Health check endpoints** - Add health check endpoints for monitoring
- **Why:** Monitoring systems need to check if the app is healthy. Health endpoints enable automated monitoring, load balancer health checks, and quick status verification.
- [ ] **Monitoring and alerting** - Create comprehensive monitoring and alerting system
- **Why:** Issues caught early are easier to fix. Monitoring provides visibility, alerts notify of problems immediately, and helps maintain uptime and performance.
### Code Quality
- [ ] **ESLint strict rules** - Add stricter ESLint rules for better code quality
- **Why:** Code quality issues lead to bugs. Stricter linting catches problems early, enforces best practices, and maintains consistent code style across the team.
- [ ] **Prettier formatting** - Implement Prettier for consistent code formatting
- **Why:** Inconsistent formatting wastes time in reviews. Prettier automates formatting, eliminates style debates, and ensures consistent code across the codebase.
- [ ] **Pre-commit hooks** - Add Husky pre-commit hooks to run linters and tests
- **Why:** Catching issues before commit saves time. Pre-commit hooks prevent bad code from entering the repo, enforce quality standards, and reduce CI failures.
- [ ] **Component library/storybook** - Create component library with Storybook documentation
- **Why:** Reusable components reduce duplication. Storybook documents components, enables isolated development, and helps maintain design consistency.
- [ ] **TypeScript strict mode** - Enable TypeScript strict mode for better type safety
- **Why:** Type errors cause runtime bugs. Strict mode catches more errors at compile time, improves code quality, and provides better IDE support and refactoring safety.
- [ ] **Error tracking** - Integrate error tracking (Sentry, LogRocket) for production monitoring
- **Why:** Production errors are hard to debug without context. Error tracking provides stack traces, user context, and helps prioritize and fix bugs quickly.
- [ ] **Performance monitoring** - Add performance monitoring (New Relic, Datadog)
- **Why:** Slow performance hurts user experience. Performance monitoring identifies bottlenecks, tracks metrics over time, and helps optimize critical paths.
### DevOps & Infrastructure
- [ ] **CI/CD pipeline** - Set up continuous integration and deployment pipeline
- **Why:** Manual deployments are error-prone and slow. CI/CD automates testing and deployment, reduces human error, enables faster releases, and improves confidence in changes.
- [ ] **Automated deployments** - Implement automated deployments on merge to main
- **Why:** Manual deployments delay releases and can be forgotten. Automation ensures consistent deployments, reduces downtime, and enables rapid iteration.
- [ ] **Blue-green deployments** - Set up blue-green deployment strategy for zero downtime
- **Why:** Deployments cause downtime and risk. Blue-green deployments enable zero-downtime updates, instant rollbacks, and reduce deployment risk significantly.
- [ ] **Database migration strategy** - Create proper database migration strategy and rollback plan
- **Why:** Database changes are risky and hard to undo. A migration strategy ensures safe, reversible changes and prevents data loss or corruption during updates.
- [ ] **Staging environment** - Create staging environment for testing before production
- **Why:** Testing in production is dangerous. Staging mirrors production, allows safe testing, and catches issues before they affect real users.
- [ ] **Automated backups** - Implement automated database and file backups
- **Why:** Data loss is catastrophic. Automated backups protect against disasters, enable recovery, and are essential for any production system.
- [ ] **Monitoring and logging** - Set up ELK stack or Grafana for centralized logging and monitoring
- **Why:** Logs are essential for debugging and monitoring. Centralized logging makes logs searchable, enables alerting, and provides visibility into system behavior.
- [ ] **Container orchestration** - Set up Docker and Kubernetes for container orchestration
- **Why:** Containers provide consistency and scalability. Kubernetes enables easy scaling, rolling updates, and manages infrastructure, making deployment and operations easier.
---
## Priority Recommendations
### High Priority (Quick Wins)
1. **Image Optimization** - Use Next.js Image component for automatic optimization
3. **Loading States** - Add loading skeletons for better UX
4. **Toast Notifications** - Implement toast notifications for user feedback
5. **Error Boundaries** - Add error boundaries for graceful error handling
6. **Rate Limiting** - Add rate limiting to prevent abuse
### Medium Priority (Significant Impact)
1. **Caching** - Implement Redis caching for leaderboard and frequently accessed data
2. **Dark Mode** - Add dark mode support for user preference
3. **Search Functionality** - Add photo search and filtering capabilities
4. **Achievements System** - Implement achievements/badges for increased engagement
5. **Analytics Dashboard** - Create admin analytics dashboard for insights
### Low Priority (Nice to Have)
1. **Social Features** - Add comments, reactions, and activity feed
2. **Advanced Gamification** - Implement levels, ranks, and advanced gamification features
---
**Note:** This is a living document. Add new ideas as they come up, and mark items as completed when implemented.

View File

@ -166,12 +166,17 @@ npm start
- Photos are uploaded to `public/uploads/` directory
- Files are served via `/api/uploads/[filename]` API route
- Ensure the uploads directory has proper write permissions
**Upload Endpoints:**
- `POST /api/photos/upload` - Single photo upload (supports both file and URL uploads)
- `POST /api/photos/upload-multiple` - Multiple photo uploads in batch (used by upload page)
- Files are stored on the filesystem (not in database)
**Monitoring Activity:**
- User activity is logged to console/systemd logs
- Watch logs in real-time: `sudo journalctl -u app-backend -f | grep -E "\[ACTIVITY\]|\[PHOTO_UPLOAD\]|\[GUESS_SUBMIT\]"`
- Activity logs include: page visits, photo uploads, guess submissions
- **Note:** For local development, use `./watch-activity.sh` script (if systemd/journalctl is not available, check application logs directly)
## Database Commands

69
REBUILD.md Normal file
View File

@ -0,0 +1,69 @@
# Rebuild Scripts
## Quick Start
### Production Mode (Recommended for testing)
```bash
./rebuild.sh prod
# or just
./rebuild.sh
```
### Development Mode (Hot reload)
```bash
./rebuild.sh dev
```
## What it does
1. **Kills all processes** - Stops any running Node/Next.js processes
2. **Frees ports** - Ensures ports 3000 and 3003 are available
3. **Cleans build artifacts** - Removes `.next`, cache files, etc.
4. **Rebuilds** (production only) - Runs `npm run build`
5. **Starts server** - Runs in foreground (dev) or background (prod)
## Viewing Logs
### Production Mode
```bash
tail -f /tmp/mirrormatch-server.log
```
### Development Mode
Logs appear directly in the terminal (foreground mode)
### Watching Activity Logs
```bash
# If using rebuild.sh (production mode)
./watch-activity.sh /tmp/mirrormatch-server.log
# If using systemd service
./watch-activity.sh
# Or specify custom log file
./watch-activity.sh /path/to/your/logfile.log
```
## Manual Commands
If you prefer to run commands manually:
```bash
# Kill everything
sudo fuser -k 3000/tcp
killall -9 node
pkill -f "next"
sleep 2
# Clean
cd /home/beast/Code/mirrormatch
rm -rf .next node_modules/.cache
# Rebuild (production)
npm run build
# Start
NODE_ENV=production npm run start > /tmp/server.log 2>&1 &
# or for dev
NODE_ENV=development npm run dev
```

View File

@ -0,0 +1,117 @@
import { render, screen, fireEvent, act } from "@testing-library/react"
import HelpModal from "@/components/HelpModal"
describe("HelpModal", () => {
it("should not render initially", () => {
render(<HelpModal />)
expect(screen.queryByText("Welcome to MirrorMatch")).not.toBeInTheDocument()
})
it("should open when Shift+? is pressed", () => {
render(<HelpModal />)
// Simulate Shift+? (Shift+/)
act(() => {
fireEvent.keyDown(window, {
key: "/",
shiftKey: true,
ctrlKey: false,
metaKey: false,
})
})
expect(screen.getByText("Welcome to MirrorMatch")).toBeInTheDocument()
})
it("should close when Escape is pressed", () => {
render(<HelpModal />)
// Open modal first
act(() => {
fireEvent.keyDown(window, {
key: "/",
shiftKey: true,
ctrlKey: false,
metaKey: false,
})
})
expect(screen.getByText("Welcome to MirrorMatch")).toBeInTheDocument()
// Close with Escape
act(() => {
fireEvent.keyDown(window, {
key: "Escape",
})
})
// Modal should close
expect(screen.queryByText("Welcome to MirrorMatch")).not.toBeInTheDocument()
})
it("should not open with Ctrl+? (should require Shift only)", () => {
render(<HelpModal />)
// Simulate Ctrl+? (Ctrl+Shift+/)
act(() => {
fireEvent.keyDown(window, {
key: "/",
shiftKey: true,
ctrlKey: true, // Should prevent opening
metaKey: false,
})
})
// Modal should not open when Ctrl is also pressed
expect(screen.queryByText("Welcome to MirrorMatch")).not.toBeInTheDocument()
})
it("should toggle when Shift+? is pressed multiple times", () => {
render(<HelpModal />)
// First press - should open
act(() => {
fireEvent.keyDown(window, {
key: "/",
shiftKey: true,
ctrlKey: false,
metaKey: false,
})
})
expect(screen.getByText("Welcome to MirrorMatch")).toBeInTheDocument()
// Second press - should close
act(() => {
fireEvent.keyDown(window, {
key: "/",
shiftKey: true,
ctrlKey: false,
metaKey: false,
})
})
expect(screen.queryByText("Welcome to MirrorMatch")).not.toBeInTheDocument()
})
it("should display help content when open", () => {
render(<HelpModal />)
// Open modal
act(() => {
fireEvent.keyDown(window, {
key: "/",
shiftKey: true,
ctrlKey: false,
metaKey: false,
})
})
// Check for key content sections
expect(screen.getByText("What is MirrorMatch?")).toBeInTheDocument()
expect(screen.getByText("How to Play")).toBeInTheDocument()
expect(screen.getByText("Key Features")).toBeInTheDocument()
expect(screen.getByText("Keyboard Shortcuts")).toBeInTheDocument()
expect(screen.getByText("Tips")).toBeInTheDocument()
})
})

View File

@ -0,0 +1,209 @@
import { logActivity } from '@/lib/activity-log';
import { logger } from '@/lib/logger';
// Mock the logger
jest.mock('@/lib/logger', () => ({
logger: {
info: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
// Helper to create a mock Request object
function createMockRequest(headers: Record<string, string> = {}): Request {
const mockHeaders = new Headers();
Object.entries(headers).forEach(([key, value]) => {
mockHeaders.set(key, value);
});
return {
headers: mockHeaders,
} as unknown as Request;
}
describe('activity-log', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('logActivity', () => {
it('should create activity log with all fields', () => {
const mockRequest = createMockRequest({
'x-forwarded-for': '192.168.1.1',
});
const user = {
id: 'user-123',
email: 'test@example.com',
role: 'USER',
};
const details = { photoId: 'photo-456' };
const result = logActivity(
'PHOTO_UPLOAD',
'/api/photos/upload',
'POST',
user,
details,
mockRequest
);
expect(result).toMatchObject({
action: 'PHOTO_UPLOAD',
path: '/api/photos/upload',
method: 'POST',
userId: 'user-123',
userEmail: 'test@example.com',
userRole: 'USER',
ip: '192.168.1.1',
details: { photoId: 'photo-456' },
});
expect(result.timestamp).toBeDefined();
});
it('should handle unauthenticated users', () => {
const result = logActivity(
'PAGE_VIEW',
'/photos',
'GET',
null,
undefined,
undefined
);
expect(result).toMatchObject({
action: 'PAGE_VIEW',
path: '/photos',
method: 'GET',
userId: undefined,
userEmail: undefined,
userRole: undefined,
ip: 'unknown',
});
});
it('should extract IP from x-forwarded-for header', () => {
const mockRequest = createMockRequest({
'x-forwarded-for': '192.168.1.1, 10.0.0.1',
});
const result = logActivity(
'ACTION',
'/path',
'GET',
undefined,
undefined,
mockRequest
);
expect(result.ip).toBe('192.168.1.1');
});
it('should extract IP from x-real-ip header when x-forwarded-for is missing', () => {
const mockRequest = createMockRequest({
'x-real-ip': '10.0.0.1',
});
const result = logActivity(
'ACTION',
'/path',
'GET',
undefined,
undefined,
mockRequest
);
expect(result.ip).toBe('10.0.0.1');
});
it('should use "unknown" for IP when no headers are present', () => {
const mockRequest = createMockRequest();
const result = logActivity(
'ACTION',
'/path',
'GET',
undefined,
undefined,
mockRequest
);
expect(result.ip).toBe('unknown');
});
it('should call logger.info with structured data', () => {
const user = {
id: 'user-123',
email: 'test@example.com',
role: 'USER',
};
const details = { photoId: 'photo-456' };
logActivity(
'PHOTO_UPLOAD',
'/api/photos/upload',
'POST',
user,
details
);
expect(logger.info).toHaveBeenCalledWith(
'Activity: PHOTO_UPLOAD',
expect.objectContaining({
method: 'POST',
path: '/api/photos/upload',
userId: 'user-123',
userEmail: 'test@example.com',
userRole: 'USER',
details: { photoId: 'photo-456' },
})
);
});
it('should not include details in logger call when details are not provided', () => {
const user = {
id: 'user-123',
email: 'test@example.com',
role: 'USER',
};
logActivity(
'PAGE_VIEW',
'/photos',
'GET',
user
);
expect(logger.info).toHaveBeenCalledWith(
'Activity: PAGE_VIEW',
expect.objectContaining({
method: 'GET',
path: '/photos',
userId: 'user-123',
userEmail: 'test@example.com',
userRole: 'USER',
})
);
const callArgs = (logger.info as jest.Mock).mock.calls[0][1];
expect(callArgs).not.toHaveProperty('details');
});
it('should handle empty details object', () => {
const result = logActivity(
'ACTION',
'/path',
'GET',
undefined,
{}
);
expect(result.details).toEqual({});
expect(logger.info).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,210 @@
import { logger, LogLevel, getLogLevel, formatLog, createLogger } from '@/lib/logger';
// Mock console methods
const originalConsole = { ...console };
const mockConsole = {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
describe('Logger', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
jest.clearAllMocks();
console.log = mockConsole.log;
console.warn = mockConsole.warn;
console.error = mockConsole.error;
// Reset environment variables
process.env = { ...originalEnv };
// Use type assertion to allow deletion
delete (process.env as { LOG_LEVEL?: string }).LOG_LEVEL;
delete (process.env as { LOG_FORMAT?: string }).LOG_FORMAT;
delete (process.env as { NODE_ENV?: string }).NODE_ENV;
});
afterEach(() => {
process.env = originalEnv;
});
afterEach(() => {
console.log = originalConsole.log;
console.warn = originalConsole.warn;
console.error = originalConsole.error;
});
describe('getLogLevel', () => {
it('should return DEBUG when LOG_LEVEL=DEBUG', () => {
process.env.LOG_LEVEL = 'DEBUG';
expect(getLogLevel()).toBe(LogLevel.DEBUG);
});
it('should return INFO when LOG_LEVEL=INFO', () => {
process.env.LOG_LEVEL = 'INFO';
expect(getLogLevel()).toBe(LogLevel.INFO);
});
it('should return WARN when LOG_LEVEL=WARN', () => {
process.env.LOG_LEVEL = 'WARN';
expect(getLogLevel()).toBe(LogLevel.WARN);
});
it('should return ERROR when LOG_LEVEL=ERROR', () => {
process.env.LOG_LEVEL = 'ERROR';
expect(getLogLevel()).toBe(LogLevel.ERROR);
});
it('should return NONE when LOG_LEVEL=NONE', () => {
process.env.LOG_LEVEL = 'NONE';
expect(getLogLevel()).toBe(LogLevel.NONE);
});
it('should default to DEBUG in development', () => {
(process.env as { NODE_ENV?: string }).NODE_ENV = 'development';
expect(getLogLevel()).toBe(LogLevel.DEBUG);
});
it('should default to INFO in production', () => {
(process.env as { NODE_ENV?: string }).NODE_ENV = 'production';
expect(getLogLevel()).toBe(LogLevel.INFO);
});
it('should ignore invalid LOG_LEVEL values and use defaults', () => {
(process.env as { LOG_LEVEL?: string }).LOG_LEVEL = 'INVALID';
(process.env as { NODE_ENV?: string }).NODE_ENV = 'production';
expect(getLogLevel()).toBe(LogLevel.INFO);
});
});
describe('formatLog', () => {
it('should format log in human-readable format by default', () => {
const result = formatLog(LogLevel.INFO, 'Test message', { key: 'value' });
expect(result).toContain('[INFO]');
expect(result).toContain('Test message');
expect(result).toContain('{"key":"value"}');
});
it('should format log as JSON when LOG_FORMAT=json', () => {
process.env.LOG_FORMAT = 'json';
const result = formatLog(LogLevel.INFO, 'Test message', { key: 'value' });
const parsed = JSON.parse(result);
expect(parsed.level).toBe('INFO');
expect(parsed.message).toBe('Test message');
expect(parsed.key).toBe('value');
expect(parsed.timestamp).toBeDefined();
});
it('should format Error objects correctly', () => {
const error = new Error('Test error');
const result = formatLog(LogLevel.ERROR, 'Error occurred', error);
expect(result).toContain('[ERROR]');
expect(result).toContain('Error occurred');
expect(result).toContain('Error: Error: Test error');
});
it('should format Error objects as JSON when LOG_FORMAT=json', () => {
process.env.LOG_FORMAT = 'json';
const error = new Error('Test error');
const result = formatLog(LogLevel.ERROR, 'Error occurred', error);
const parsed = JSON.parse(result);
expect(parsed.level).toBe('ERROR');
expect(parsed.message).toBe('Error occurred');
expect(parsed.error.name).toBe('Error');
expect(parsed.error.message).toBe('Test error');
expect(parsed.error.stack).toBeDefined();
});
it('should handle logs without context', () => {
const result = formatLog(LogLevel.INFO, 'Simple message');
expect(result).toContain('[INFO]');
expect(result).toContain('Simple message');
// Format always includes pipe separator, but no context data after it
expect(result).toContain('|');
expect(result.split('|').length).toBe(2); // timestamp | message (no context)
});
});
describe('Logger instance', () => {
it('should log DEBUG messages when level is DEBUG', () => {
process.env.LOG_LEVEL = 'DEBUG';
const testLogger = createLogger();
testLogger.debug('Debug message', { data: 'test' });
expect(mockConsole.log).toHaveBeenCalled();
});
it('should not log DEBUG messages when level is INFO', () => {
process.env.LOG_LEVEL = 'INFO';
const testLogger = createLogger();
testLogger.debug('Debug message');
expect(mockConsole.log).not.toHaveBeenCalled();
});
it('should log INFO messages when level is INFO', () => {
process.env.LOG_LEVEL = 'INFO';
const testLogger = createLogger();
testLogger.info('Info message');
expect(mockConsole.log).toHaveBeenCalled();
});
it('should log WARN messages when level is WARN', () => {
process.env.LOG_LEVEL = 'WARN';
const testLogger = createLogger();
testLogger.warn('Warning message');
expect(mockConsole.warn).toHaveBeenCalled();
});
it('should not log INFO messages when level is WARN', () => {
process.env.LOG_LEVEL = 'WARN';
const testLogger = createLogger();
testLogger.info('Info message');
expect(mockConsole.log).not.toHaveBeenCalled();
});
it('should log ERROR messages when level is ERROR', () => {
process.env.LOG_LEVEL = 'ERROR';
const testLogger = createLogger();
testLogger.error('Error message');
expect(mockConsole.error).toHaveBeenCalled();
});
it('should not log any messages when level is NONE', () => {
process.env.LOG_LEVEL = 'NONE';
const testLogger = createLogger();
testLogger.debug('Debug message');
testLogger.info('Info message');
testLogger.warn('Warning message');
testLogger.error('Error message');
expect(mockConsole.log).not.toHaveBeenCalled();
expect(mockConsole.warn).not.toHaveBeenCalled();
expect(mockConsole.error).not.toHaveBeenCalled();
});
it('should handle Error objects in error method', () => {
process.env.LOG_LEVEL = 'ERROR';
const testLogger = createLogger();
const error = new Error('Test error');
testLogger.error('Error occurred', error);
expect(mockConsole.error).toHaveBeenCalled();
const callArgs = mockConsole.error.mock.calls[0][0];
expect(callArgs).toContain('Error occurred');
});
it('isLevelEnabled should return correct values', () => {
process.env.LOG_LEVEL = 'WARN';
const testLogger = createLogger();
expect(testLogger.isLevelEnabled(LogLevel.DEBUG)).toBe(false);
expect(testLogger.isLevelEnabled(LogLevel.INFO)).toBe(false);
expect(testLogger.isLevelEnabled(LogLevel.WARN)).toBe(true);
expect(testLogger.isLevelEnabled(LogLevel.ERROR)).toBe(true);
});
});
describe('Default logger instance', () => {
it('should be available and functional', () => {
process.env.LOG_LEVEL = 'INFO';
logger.info('Test message');
expect(mockConsole.log).toHaveBeenCalled();
});
});
});

View File

@ -1,12 +1,17 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger"
import { hashPassword } from "@/lib/utils"
// Mark this route as dynamic to prevent build-time data collection
export const dynamic = "force-dynamic"
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ userId: string }> }
) {
let userId: string | undefined
try {
const session = await auth()
@ -14,7 +19,7 @@ export async function POST(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { userId } = await params
userId = (await params).userId
const { password } = await req.json()
if (!password || password.length < 6) {
@ -33,7 +38,10 @@ export async function POST(
return NextResponse.json({ success: true })
} catch (error) {
console.error("Error resetting password:", error)
logger.error("Error resetting password", {
userId,
error: error instanceof Error ? error : new Error(String(error)),
})
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }

View File

@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { hashPassword } from "@/lib/utils"
import { logger } from "@/lib/logger"
// Mark this route as dynamic to prevent build-time data collection
export const dynamic = "force-dynamic"
export async function POST(req: NextRequest) {
try {
@ -53,7 +57,9 @@ export async function POST(req: NextRequest) {
{ status: 201 }
)
} catch (error) {
console.error("Error creating user:", error)
logger.error("Error creating user", {
error: error instanceof Error ? error : new Error(String(error)),
})
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }

View File

@ -1,3 +1,7 @@
import { handlers } from "@/lib/auth"
// Mark this route as dynamic to prevent build-time data collection
// NextAuth requires runtime environment variables and cannot be pre-rendered
export const dynamic = "force-dynamic"
export const { GET, POST } = handlers

View File

@ -1,9 +1,32 @@
import { auth } from "@/lib/auth"
import { NextResponse } from "next/server"
import { cookies } from "next/headers"
import { SESSION_COOKIE_NAME } from "@/lib/constants"
import { logger } from "@/lib/logger"
// Mark this route as dynamic to prevent build-time data collection
export const dynamic = "force-dynamic"
/**
* Debug endpoint for session inspection
* ADMIN ONLY - Protected endpoint for debugging session issues
*
* This endpoint should only be accessible to administrators.
* Consider removing in production or restricting further.
*/
export async function GET(request: Request) {
try {
// Require admin authentication
const session = await auth()
if (!session || session.user.role !== "ADMIN") {
logger.warn("Unauthorized access attempt to debug endpoint", {
userId: session?.user?.id,
userRole: session?.user?.role,
path: "/api/debug/session",
})
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const cookieHeader = request.headers.get("cookie") || ""
// Parse cookies from header first
@ -16,29 +39,29 @@ export async function GET(request: Request) {
})
// Try to get session token from cookies
const sessionTokenFromHeader = cookieMap["__Secure-authjs.session-token"] || "NOT FOUND"
const sessionTokenFromHeader = cookieMap[SESSION_COOKIE_NAME] || "NOT FOUND"
// Try to call auth() - this might fail or return null
let session = null
// Try to call auth() again for debugging (we already have session above, but this is for testing)
let authError = null
try {
console.log("Debug endpoint: Calling auth()...")
session = await auth()
console.log("Debug endpoint: auth() returned", {
// Already called above, but keeping for backward compatibility in response
logger.debug("Debug endpoint: Session retrieved", {
hasSession: !!session,
sessionUser: session?.user,
sessionKeys: session ? Object.keys(session) : []
userId: session?.user?.id,
userRole: session?.user?.role,
})
} catch (err) {
authError = err instanceof Error ? err.message : String(err)
console.error("Debug endpoint: auth() error", authError)
logger.error("Debug endpoint: auth() error", {
error: err instanceof Error ? err : new Error(String(err)),
})
}
// Try to get cookie from Next.js cookie store
let sessionTokenFromStore = "NOT ACCESSIBLE"
try {
const cookieStore = await cookies()
sessionTokenFromStore = cookieStore.get("__Secure-authjs.session-token")?.value || "NOT FOUND"
sessionTokenFromStore = cookieStore.get(SESSION_COOKIE_NAME)?.value || "NOT FOUND"
} catch {
// Cookie store might not be accessible in all contexts
}

View File

@ -3,6 +3,10 @@ import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { normalizeString } from "@/lib/utils"
import { logActivity } from "@/lib/activity-log"
import { logger } from "@/lib/logger"
// Mark this route as dynamic to prevent build-time data collection
export const dynamic = "force-dynamic"
export async function POST(
req: NextRequest,
@ -151,7 +155,9 @@ export async function POST(
pointsChange
})
} catch (error) {
console.error("Error submitting guess:", error)
logger.error("Error submitting guess", {
error: error instanceof Error ? error : new Error(String(error)),
})
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }

View File

@ -1,10 +1,14 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger"
import { unlink } from "fs/promises"
import { join } from "path"
import { existsSync } from "fs"
// Mark this route as dynamic to prevent build-time data collection
export const dynamic = "force-dynamic"
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ photoId: string }> }
@ -47,7 +51,10 @@ export async function DELETE(
try {
await unlink(filepath)
} catch (error) {
console.error("Failed to delete file:", filepath, error)
logger.error("Failed to delete file", {
filepath,
error: error instanceof Error ? error : new Error(String(error)),
})
// Continue with database deletion even if file deletion fails
}
}
@ -60,7 +67,9 @@ export async function DELETE(
return NextResponse.json({ success: true, message: "Photo deleted successfully" })
} catch (error) {
console.error("Error deleting photo:", error)
logger.error("Error deleting photo", {
error: error instanceof Error ? error : new Error(String(error)),
})
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }

View File

@ -1,91 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { sendNewPhotoEmail } from "@/lib/email"
// Legacy endpoint for URL-based uploads (kept for backward compatibility)
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { url, answerName, points, maxAttempts } = await req.json()
if (!url || !answerName) {
return NextResponse.json(
{ error: "URL and answer name are required" },
{ status: 400 }
)
}
// Validate points (must be positive integer, default to 1)
const pointsValue = points ? Math.max(1, parseInt(points, 10)) : 1
const maxAttemptsValue = maxAttempts && parseInt(maxAttempts, 10) > 0
? parseInt(maxAttempts, 10)
: null
// Check for duplicate URL
const existingPhoto = await prisma.photo.findFirst({
where: { url },
})
if (existingPhoto) {
return NextResponse.json(
{ error: "This photo URL has already been uploaded (duplicate URL detected)" },
{ status: 409 }
)
}
const photo = await prisma.photo.create({
data: {
uploaderId: session.user.id,
url,
answerName: answerName.trim(),
points: pointsValue,
maxAttempts: maxAttemptsValue,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
include: {
uploader: {
select: {
name: true,
},
},
},
})
// Send emails to all other users
const allUsers = await prisma.user.findMany({
where: {
id: { not: session.user.id },
},
select: {
id: true,
email: true,
name: true,
},
})
// Send emails asynchronously (don't wait for them)
Promise.all(
allUsers.map((user: { id: string; email: string; name: string }) =>
sendNewPhotoEmail(user.email, user.name, photo.id, photo.uploader.name).catch(
(err) => {
console.error("Failed to send email to:", user.email, err)
}
)
)
)
return NextResponse.json({ photo }, { status: 201 })
} catch (error) {
console.error("Error creating photo:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

View File

@ -1,12 +1,39 @@
/**
* Multiple Photo Upload Endpoint
*
* POST /api/photos/upload-multiple
*
* Uploads multiple photos in a single request. Supports both file uploads and URL-based uploads.
*
* This endpoint is used by the upload page for batch uploads. It processes multiple photos
* in parallel and sends email notifications for all successfully uploaded photos.
*
* Form Data:
* - photo_{index}_file: File object (optional, if using file upload)
* - photo_{index}_url: URL string (optional, if using URL upload)
* - photo_{index}_answerName: Answer name (required)
* - photo_{index}_points: Points value (optional, defaults to 1)
* - photo_{index}_penaltyEnabled: "true" or "false" (optional)
* - photo_{index}_penaltyPoints: Penalty points (optional)
* - photo_{index}_maxAttempts: Maximum attempts (optional)
* - count: Number of photos being uploaded
*
* Related endpoints:
* - POST /api/photos/upload - Single photo upload (supports both file and URL)
*/
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { sendNewPhotoEmail } from "@/lib/email"
import { logger } from "@/lib/logger"
import { writeFile } from "fs/promises"
import { join } from "path"
import { existsSync, mkdirSync } from "fs"
import { createHash } from "crypto"
// Mark this route as dynamic to prevent build-time data collection
export const dynamic = "force-dynamic"
export async function POST(req: NextRequest) {
try {
const session = await auth()
@ -204,7 +231,11 @@ export async function POST(req: NextRequest) {
photo.id,
photo.uploader.name
).catch((err) => {
console.error("Failed to send email to:", user.email, "for photo:", photo.id, err)
logger.error("Failed to send email", {
email: user.email,
photoId: photo.id,
error: err instanceof Error ? err : new Error(String(err)),
})
})
)
)
@ -216,7 +247,9 @@ export async function POST(req: NextRequest) {
{ status: 201 }
)
} catch (error) {
console.error("Error uploading photos:", error)
logger.error("Error uploading photos", {
error: error instanceof Error ? error : new Error(String(error)),
})
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }

View File

@ -3,11 +3,15 @@ import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { sendNewPhotoEmail } from "@/lib/email"
import { logActivity } from "@/lib/activity-log"
import { logger } from "@/lib/logger"
import { writeFile } from "fs/promises"
import { join } from "path"
import { existsSync, mkdirSync } from "fs"
import { createHash } from "crypto"
// Mark this route as dynamic to prevent build-time data collection
export const dynamic = "force-dynamic"
export async function POST(req: NextRequest) {
try {
const session = await auth()
@ -86,7 +90,8 @@ export async function POST(req: NextRequest) {
const uploadsDir = join(process.cwd(), "public", "uploads")
if (!existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true })
console.log(`[UPLOAD] Created uploads directory: ${uploadsDir}`)
// DEBUG level: directory creation is normal operation
logger.debug("Created uploads directory", { path: uploadsDir })
}
console.log(`[UPLOAD] Using uploads directory: ${uploadsDir} (exists: ${existsSync(uploadsDir)})`)
@ -98,9 +103,14 @@ export async function POST(req: NextRequest) {
const { access } = await import("fs/promises")
try {
await access(filepath)
console.log(`[UPLOAD] File saved successfully: ${filepath}`)
// DEBUG level: file save verification is normal operation
logger.debug("File saved successfully", { filepath })
} catch (error) {
console.error(`[UPLOAD] File write verification failed: ${filepath}`, error)
// ERROR level: file write failure is an error condition
logger.error("File write verification failed", {
filepath,
error: error instanceof Error ? error : new Error(String(error)),
})
throw new Error("Failed to save file to disk")
}
@ -166,7 +176,11 @@ export async function POST(req: NextRequest) {
allUsers.map((user: { id: string; email: string; name: string }) =>
sendNewPhotoEmail(user.email, user.name, photo.id, photo.uploader.name).catch(
(err) => {
console.error("Failed to send email to:", user.email, err)
logger.error("Failed to send email", {
email: user.email,
photoId: photo.id,
error: err instanceof Error ? err : new Error(String(err)),
})
}
)
)
@ -189,7 +203,9 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ photo }, { status: 201 })
} catch (error) {
console.error("Error uploading photo:", error)
logger.error("Error uploading photo", {
error: error instanceof Error ? error : new Error(String(error)),
})
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }

View File

@ -1,9 +1,13 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger"
import bcrypt from "bcryptjs"
import { hashPassword } from "@/lib/utils"
// Mark this route as dynamic to prevent build-time data collection
export const dynamic = "force-dynamic"
export async function POST(req: NextRequest) {
try {
const session = await auth()
@ -44,7 +48,9 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true })
} catch (error) {
console.error("Error changing password:", error)
logger.error("Error changing password", {
error: error instanceof Error ? error : new Error(String(error)),
})
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }

View File

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"
import { logger } from "@/lib/logger"
import { readFile } from "fs/promises"
import { join } from "path"
import { existsSync } from "fs"
@ -7,8 +8,9 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
let filename: string | undefined
try {
const { filename } = await params
filename = (await params).filename
// Sanitize filename - only allow alphanumeric, dots, hyphens
if (!/^[a-zA-Z0-9._-]+$/.test(filename)) {
@ -26,7 +28,11 @@ export async function GET(
// Check if file exists
if (!existsSync(filepath)) {
console.error(`[UPLOAD] File not found: ${filepath} (cwd: ${process.cwd()})`)
logger.warn("File not found", {
filepath,
filename,
cwd: process.cwd(),
})
return NextResponse.json({ error: "File not found" }, { status: 404 })
}
@ -49,7 +55,10 @@ export async function GET(
},
})
} catch (error) {
console.error("[UPLOAD] Error serving file:", error)
logger.error("Error serving file", {
filename: filename || "unknown",
error: error instanceof Error ? error : new Error(String(error)),
})
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }

View File

@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Providers from "@/components/Providers";
import Navigation from "@/components/Navigation";
import HelpModal from "@/components/HelpModal";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -32,6 +33,7 @@ export default function RootLayout({
<Providers>
<Navigation />
<main className="min-h-screen bg-gray-50 overflow-x-hidden">{children}</main>
<HelpModal />
</Providers>
</body>
</html>

View File

@ -26,13 +26,21 @@ export default function LoginPage() {
if (result?.error) {
setError("Invalid email or password")
} else if (result?.ok) {
// Force a session refresh before redirect
// This ensures the session cookie is properly set and read
await fetch("/api/auth/session", { method: "GET" })
// Check if there's a callback URL in the query params
const params = new URLSearchParams(window.location.search)
const callbackUrl = params.get("callbackUrl") || "/photos"
console.log("Redirecting to:", callbackUrl)
// Use window.location.href to force a full page reload
// This ensures the session cookie is read before middleware checks authentication
window.location.href = callbackUrl
// Small delay to ensure cookie is set
setTimeout(() => {
// Use window.location.href to force a full page reload
// This ensures the session cookie is read before middleware checks authentication
window.location.href = callbackUrl
}, 100)
} else {
setError("Login failed. Please try again.")
}

View File

@ -4,33 +4,34 @@ import { prisma } from "@/lib/prisma"
import Link from "next/link"
import PhotoThumbnail from "@/components/PhotoThumbnail"
import DeletePhotoButton from "@/components/DeletePhotoButton"
import { logger } from "@/lib/logger"
// Enable caching for this page
export const revalidate = 60 // Revalidate every 60 seconds
export default async function PhotosPage() {
console.log("PhotosPage: Starting, calling auth()...")
// DEBUG level: only logs in development or when LOG_LEVEL=DEBUG
logger.debug("PhotosPage: Starting, calling auth()")
const session = await auth()
console.log("PhotosPage: auth() returned", {
hasSession: !!session,
sessionType: typeof session,
sessionUser: session?.user,
sessionKeys: session ? Object.keys(session) : [],
sessionString: JSON.stringify(session, null, 2)
})
if (!session) {
console.log("PhotosPage: No session, redirecting to login")
logger.debug("PhotosPage: No session, redirecting to login")
redirect("/login")
}
if (!session.user) {
console.log("PhotosPage: Session exists but no user, redirecting to login")
// WARN level: session exists but no user is a warning condition
logger.warn("PhotosPage: Session exists but no user, redirecting to login", {
hasSession: !!session,
sessionKeys: session ? Object.keys(session) : [],
})
redirect("/login")
}
console.log("PhotosPage: Session valid, rendering page")
logger.debug("PhotosPage: Session valid, rendering page", {
userId: session.user.id,
userEmail: session.user.email,
})
// Limit to 50 photos per page for performance
const photos = await prisma.photo.findMany({

285
components/HelpModal.tsx Normal file
View File

@ -0,0 +1,285 @@
"use client"
import { useState, useEffect } from "react"
export default function HelpModal() {
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Check for Shift+? (Shift+/ produces "?")
// Also check for event.key === "?" as fallback for some keyboard layouts
const isQuestionMark =
(event.shiftKey && event.key === "/") ||
event.key === "?" ||
(event.code === "Slash" && event.shiftKey)
// Only trigger if Shift is pressed (not Ctrl/Cmd)
if (event.shiftKey && isQuestionMark && !event.ctrlKey && !event.metaKey) {
event.preventDefault()
setIsOpen((prev) => !prev)
}
// Also close on Escape key
if (event.key === "Escape" && isOpen) {
setIsOpen(false)
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [isOpen])
if (!isOpen) return null
return (
<>
{/* Overlay */}
<div
className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
onClick={() => setIsOpen(false)}
>
{/* Modal */}
<div
className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto z-50"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 bg-gradient-to-r from-purple-600 to-indigo-600 text-white p-6 rounded-t-lg flex justify-between items-center">
<h2 className="text-2xl font-bold">Welcome to MirrorMatch</h2>
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-purple-700 rounded-md transition"
aria-label="Close help"
>
<svg
className="h-6 w-6"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6 text-gray-700">
{/* What is MirrorMatch */}
<section>
<h3 className="text-xl font-semibold text-gray-900 mb-3 flex items-center">
<svg
className="h-5 w-5 mr-2 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
What is MirrorMatch?
</h3>
<p className="text-gray-700 leading-relaxed">
MirrorMatch is a fun and engaging photo guessing game where you can upload photos
and challenge other players to guess who is in the picture. Test your knowledge of
friends, family, or colleagues while competing for points on the leaderboard!
</p>
</section>
{/* How to Play */}
<section>
<h3 className="text-xl font-semibold text-gray-900 mb-3 flex items-center">
<svg
className="h-5 w-5 mr-2 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
How to Play
</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>
<strong>Upload Photos:</strong> Go to the Upload page and add photos with answer
names. You can upload files or use image URLs.
</li>
<li>
<strong>Guess Photos:</strong> Browse the Photos page and click on any photo to
make your guess. Enter the name of the person you think is in the picture.
</li>
<li>
<strong>Earn Points:</strong> Get points for each correct guess! The more you
guess correctly, the higher you&apos;ll climb on the leaderboard.
</li>
<li>
<strong>Compete:</strong> Check the Leaderboard to see how you rank against other
players.
</li>
</ol>
</section>
{/* Features */}
<section>
<h3 className="text-xl font-semibold text-gray-900 mb-3 flex items-center">
<svg
className="h-5 w-5 mr-2 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
/>
</svg>
Key Features
</h3>
<ul className="space-y-2 text-gray-700">
<li className="flex items-start">
<span className="text-purple-600 mr-2"></span>
<span>
<strong>Photo Upload:</strong> Share photos via file upload or URL
</span>
</li>
<li className="flex items-start">
<span className="text-purple-600 mr-2"></span>
<span>
<strong>Guessing System:</strong> Submit guesses and get instant feedback
</span>
</li>
<li className="flex items-start">
<span className="text-purple-600 mr-2"></span>
<span>
<strong>Email Notifications:</strong> Get notified when new photos are
uploaded
</span>
</li>
<li className="flex items-start">
<span className="text-purple-600 mr-2"></span>
<span>
<strong>Leaderboard:</strong> Track rankings and compete with others
</span>
</li>
<li className="flex items-start">
<span className="text-purple-600 mr-2"></span>
<span>
<strong>Profile Management:</strong> View your points and manage your account
</span>
</li>
</ul>
</section>
{/* Keyboard Shortcuts */}
<section>
<h3 className="text-xl font-semibold text-gray-900 mb-3 flex items-center">
<svg
className="h-5 w-5 mr-2 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
Keyboard Shortcuts
</h3>
<div className="bg-gray-50 rounded-md p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-gray-700">
<kbd className="px-2 py-1 bg-white border border-gray-300 rounded text-sm font-mono">
Shift
</kbd>
{" + "}
<kbd className="px-2 py-1 bg-white border border-gray-300 rounded text-sm font-mono">
?
</kbd>
</span>
<span className="text-gray-600">Open/Close this help window</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-700">
<kbd className="px-2 py-1 bg-white border border-gray-300 rounded text-sm font-mono">
Esc
</kbd>
</span>
<span className="text-gray-600">Close help window</span>
</div>
</div>
</section>
{/* Tips */}
<section>
<h3 className="text-xl font-semibold text-gray-900 mb-3 flex items-center">
<svg
className="h-5 w-5 mr-2 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
Tips
</h3>
<ul className="space-y-2 text-gray-700">
<li className="flex items-start">
<span className="text-purple-600 mr-2">💡</span>
<span>Guesses are case-insensitive, so don&apos;t worry about capitalization</span>
</li>
<li className="flex items-start">
<span className="text-purple-600 mr-2">💡</span>
<span>You can&apos;t guess your own photos, but you can still view them</span>
</li>
<li className="flex items-start">
<span className="text-purple-600 mr-2">💡</span>
<span>
You&apos;ll receive email notifications when other users upload new photos
</span>
</li>
<li className="flex items-start">
<span className="text-purple-600 mr-2">💡</span>
<span>Check the leaderboard regularly to see your ranking</span>
</li>
</ul>
</section>
</div>
{/* Footer */}
<div className="bg-gray-50 p-4 rounded-b-lg border-t border-gray-200">
<p className="text-sm text-gray-600 text-center">
Press <kbd className="px-2 py-1 bg-white border border-gray-300 rounded text-xs font-mono">Esc</kbd> or click outside to close
</p>
</div>
</div>
</div>
</>
)
}

45
deploy-and-watch.sh Executable file
View File

@ -0,0 +1,45 @@
#!/bin/bash
# Deploy server and watch activity logs
# Usage: ./deploy-and-watch.sh
echo "========================================="
echo "Deploying and watching activity logs"
echo "========================================="
echo ""
# Start rebuild in background
echo "Starting server deployment..."
./rebuild.sh prod &
REBUILD_PID=$!
# Wait for log file to be created (with timeout)
LOG_FILE="/tmp/mirrormatch-server.log"
MAX_WAIT=30
WAITED=0
echo "Waiting for server to start and create log file..."
while [ ! -f "$LOG_FILE" ] && [ $WAITED -lt $MAX_WAIT ]; do
sleep 1
WAITED=$((WAITED + 1))
if [ $((WAITED % 5)) -eq 0 ]; then
echo " Still waiting... ($WAITED/$MAX_WAIT seconds)"
fi
done
if [ ! -f "$LOG_FILE" ]; then
echo "⚠ Warning: Log file not created after $MAX_WAIT seconds"
echo " Check if rebuild.sh completed successfully"
echo " You can manually watch logs later: ./watch-activity.sh $LOG_FILE"
exit 1
fi
echo "✓ Log file created: $LOG_FILE"
echo ""
echo "========================================="
echo "Watching activity logs..."
echo "Press Ctrl+C to stop"
echo "========================================="
echo ""
# Watch activity logs
./watch-activity.sh "$LOG_FILE"

View File

@ -15,3 +15,9 @@ SMTP_FROM="MirrorMatch <noreply@mirrormatch.com>"
# In development, emails will be logged to console or use Ethereal
# No SMTP config needed for dev mode
# Logging Configuration
# LOG_LEVEL: DEBUG, INFO, WARN, ERROR, or NONE (default: DEBUG in dev, INFO in production)
# LOG_FORMAT: "json" for structured JSON logs, or omit for human-readable format
# LOG_LEVEL=INFO
# LOG_FORMAT=json

View File

@ -1,7 +1,10 @@
/**
* Activity logging utility for tracking user actions
* Uses structured logging with log levels for better production control
*/
import { logger } from './logger'
export interface ActivityLog {
timestamp: string
userId?: string
@ -14,6 +17,18 @@ export interface ActivityLog {
details?: Record<string, unknown>
}
/**
* Log user activity with structured data
* Uses INFO level logging - can be filtered via LOG_LEVEL environment variable
*
* @param action - The action being performed (e.g., "PHOTO_UPLOAD", "GUESS_SUBMITTED")
* @param path - The request path
* @param method - The HTTP method
* @param user - Optional user object (id, email, role)
* @param details - Optional additional context data
* @param request - Optional Request object for extracting IP address
* @returns Structured activity log object
*/
export function logActivity(
action: string,
path: string,
@ -21,7 +36,7 @@ export function logActivity(
user?: { id: string; email: string; role: string } | null,
details?: Record<string, unknown>,
request?: Request
) {
): ActivityLog {
const timestamp = new Date().toISOString()
const ip = request?.headers.get("x-forwarded-for") ||
request?.headers.get("x-real-ip") ||
@ -39,14 +54,17 @@ export function logActivity(
details
}
// Format: [ACTION] timestamp | method path | User: email (role) | IP: ip | Details: {...}
const userInfo = user
? `${user.email} (${user.role})`
: "UNAUTHENTICATED"
const detailsStr = details ? ` | Details: ${JSON.stringify(details)}` : ""
console.log(`[${action}] ${timestamp} | ${method} ${path} | User: ${userInfo} | IP: ${ip.split(",")[0].trim()}${detailsStr}`)
// Use structured logging with INFO level
// This allows filtering via LOG_LEVEL environment variable
logger.info(`Activity: ${action}`, {
method,
path,
userId: user?.id,
userEmail: user?.email,
userRole: user?.role,
ip: ip.split(",")[0].trim(),
...(details && { details }),
})
return log
}

View File

@ -2,15 +2,29 @@ import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { prisma } from "./prisma"
import bcrypt from "bcryptjs"
import { logger } from "./logger"
const nextAuthSecret = process.env.NEXTAUTH_SECRET
if (!nextAuthSecret) {
throw new Error("NEXTAUTH_SECRET is not set. Define it to enable authentication.")
// Lazy check for NEXTAUTH_SECRET - only validate when actually needed
// This prevents build-time errors when the secret isn't available
function getNextAuthSecret(): string {
const secret = process.env.NEXTAUTH_SECRET
if (!secret) {
// Always throw at runtime - this is a critical configuration error
throw new Error("NEXTAUTH_SECRET is not set. Define it to enable authentication.")
}
return secret
}
// Determine if we should use secure cookies based on AUTH_URL/NEXTAUTH_URL
// Auth.js v5 derives this from the origin it detects, so we need to be explicit
const authUrl = process.env.AUTH_URL || process.env.NEXTAUTH_URL || "http://localhost:3000"
const isHttp = authUrl.startsWith("http://")
export const { handlers, auth, signIn, signOut } = NextAuth({
// trustHost must be true for NextAuth v5 to work, even on localhost
trustHost: true,
debug: process.env.NODE_ENV !== "production",
basePath: "/api/auth",
providers: [
Credentials({
name: "Credentials",
@ -48,7 +62,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
role: user.role,
}
} catch (err) {
console.error("Auth authorize error:", err)
logger.error("Auth authorize error", err instanceof Error ? err : new Error(String(err)))
return null
}
}
@ -61,27 +75,22 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
token.role = (user as { role: string }).role
token.email = user.email
token.name = user.name
console.log("JWT callback: user added to token", { userId: user.id, email: user.email })
// DEBUG level: only logs in development or when LOG_LEVEL=DEBUG
logger.debug("JWT callback: user added to token", {
userId: user.id,
email: user.email
})
} else {
console.log("JWT callback: no user, token exists", {
// DEBUG level: token refresh (normal operation, only log in debug mode)
logger.debug("JWT callback: token refresh", {
hasToken: !!token,
tokenKeys: token ? Object.keys(token) : [],
tokenId: token?.id,
tokenEmail: token?.email,
tokenName: token?.name,
tokenRole: token?.role
})
}
return token
},
async session({ session, token }) {
console.log("Session callback: called", {
hasToken: !!token,
hasSession: !!session,
tokenId: token?.id,
tokenEmail: token?.email,
stackTrace: new Error().stack?.split('\n').slice(1, 4).join('\n')
})
// Always ensure session.user exists when token exists
if (token && (token.id || token.email)) {
session.user = {
@ -91,23 +100,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
name: (token.name as string) || session.user?.name || "",
role: token.role as string,
}
console.log("Session callback: session created", {
// DEBUG level: session creation is normal operation, only log in debug mode
logger.debug("Session callback: session created", {
userId: token.id,
email: token.email,
hasUser: !!session.user,
userKeys: session.user ? Object.keys(session.user) : [],
userRole: token.role,
sessionUser: session.user,
sessionExpires: session.expires,
fullSession: JSON.stringify(session, null, 2)
})
} else {
console.warn("Session callback: token missing or invalid", {
// WARN level: token missing/invalid is a warning condition
logger.warn("Session callback: token missing or invalid", {
hasToken: !!token,
tokenKeys: token ? Object.keys(token) : [],
hasSession: !!session,
sessionKeys: session ? Object.keys(session) : [],
sessionUser: session?.user,
tokenId: token?.id,
tokenEmail: token?.email
})
@ -124,16 +127,39 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
},
cookies: {
sessionToken: {
name: `__Secure-authjs.session-token`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: true, // Always secure in production (HTTPS required)
},
},
},
secret: nextAuthSecret,
// Explicitly configure cookies for HTTP (localhost)
// For HTTPS, let Auth.js defaults handle it (prefixes + Secure)
cookies: isHttp
? {
// localhost / pure HTTP: no prefixes, no Secure
sessionToken: {
name: "authjs.session-token",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: false,
},
},
csrfToken: {
name: "authjs.csrf-token",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: false,
},
},
callbackUrl: {
name: "authjs.callback-url",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: false,
},
},
}
: undefined, // Let Auth.js defaults handle HTTPS envs (prefixes + Secure)
secret: getNextAuthSecret(),
})

10
lib/constants.ts Normal file
View File

@ -0,0 +1,10 @@
/**
* Application-wide constants
*/
/**
* NextAuth session cookie name
* Must match the cookie name defined in lib/auth.ts
* For HTTP (localhost), no prefix. For HTTPS, Auth.js will add __Secure- prefix automatically.
*/
export const SESSION_COOKIE_NAME = "authjs.session-token"

156
lib/logger.ts Normal file
View File

@ -0,0 +1,156 @@
/**
* Structured logging utility with log levels and environment-based filtering
*
* Log levels (in order of severity):
* - DEBUG: Detailed information for debugging (only in development)
* - INFO: General informational messages
* - WARN: Warning messages for potentially harmful situations
* - ERROR: Error messages for error events
*
* Usage:
* import { logger } from '@/lib/logger'
* logger.debug('Debug message', { data })
* logger.info('Info message', { data })
* logger.warn('Warning message', { data })
* logger.error('Error message', { error })
*/
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
NONE = 4, // Disable all logging
}
export interface LogContext {
[key: string]: unknown;
}
export interface Logger {
debug(message: string, context?: LogContext): void;
info(message: string, context?: LogContext): void;
warn(message: string, context?: LogContext): void;
error(message: string, context?: LogContext | Error): void;
isLevelEnabled(level: LogLevel): boolean;
}
/**
* Parse log level from environment variable or default based on NODE_ENV
*/
function getLogLevel(): LogLevel {
const envLogLevel = process.env.LOG_LEVEL?.toUpperCase();
// If explicitly set, use that
if (envLogLevel) {
switch (envLogLevel) {
case 'DEBUG':
return LogLevel.DEBUG;
case 'INFO':
return LogLevel.INFO;
case 'WARN':
return LogLevel.WARN;
case 'ERROR':
return LogLevel.ERROR;
case 'NONE':
return LogLevel.NONE;
default:
// Invalid value, fall through to default behavior
break;
}
}
// Default behavior: DEBUG in development, INFO in production
return process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.DEBUG;
}
/**
* Format log entry as structured JSON or human-readable string
*/
function formatLog(
level: LogLevel,
message: string,
context?: LogContext | Error
): string {
const timestamp = new Date().toISOString();
const levelName = LogLevel[level];
// If structured logging is enabled, output JSON
if (process.env.LOG_FORMAT === 'json') {
const logEntry: Record<string, unknown> = {
timestamp,
level: levelName,
message,
};
if (context) {
if (context instanceof Error) {
logEntry.error = {
name: context.name,
message: context.message,
stack: context.stack,
};
} else {
Object.assign(logEntry, context);
}
}
return JSON.stringify(logEntry);
}
// Human-readable format
const contextStr = context
? context instanceof Error
? ` | Error: ${context.name}: ${context.message}`
: ` | ${JSON.stringify(context)}`
: '';
return `[${levelName}] ${timestamp} | ${message}${contextStr}`;
}
/**
* Create a logger instance with the configured log level
*/
function createLogger(): Logger {
const currentLevel = getLogLevel();
const shouldLog = (level: LogLevel): boolean => {
return level >= currentLevel;
};
return {
debug(message: string, context?: LogContext): void {
if (shouldLog(LogLevel.DEBUG)) {
console.log(formatLog(LogLevel.DEBUG, message, context));
}
},
info(message: string, context?: LogContext): void {
if (shouldLog(LogLevel.INFO)) {
console.log(formatLog(LogLevel.INFO, message, context));
}
},
warn(message: string, context?: LogContext): void {
if (shouldLog(LogLevel.WARN)) {
console.warn(formatLog(LogLevel.WARN, message, context));
}
},
error(message: string, context?: LogContext | Error): void {
if (shouldLog(LogLevel.ERROR)) {
console.error(formatLog(LogLevel.ERROR, message, context));
}
},
isLevelEnabled(level: LogLevel): boolean {
return shouldLog(level);
},
};
}
// Export singleton logger instance
export const logger = createLogger();
// Export for testing
export { getLogLevel, formatLog, createLogger };

51
merge-main-into-dev.sh Executable file
View File

@ -0,0 +1,51 @@
#!/bin/bash
# Merge main into dev to resolve conflicts before merging dev into main
set -e
echo "========================================="
echo "Merging main into dev to resolve conflicts"
echo "========================================="
cd /home/beast/Code/mirrormatch
# 1. Make sure we're on dev
echo ""
echo "Step 1: Checking out dev branch..."
git checkout dev
git pull gitea dev
# 2. Fetch latest main
echo ""
echo "Step 2: Fetching latest main..."
git fetch gitea main
# 3. Try to merge main into dev
echo ""
echo "Step 3: Merging main into dev..."
echo "This may show conflicts that need to be resolved manually."
echo ""
if git merge gitea/main --no-commit; then
echo "✓ No conflicts! Merging..."
git commit -m "Merge main into dev: resolve conflicts before MR"
echo ""
echo "✓ Merge complete! Now push dev:"
echo " git push gitea dev"
echo ""
echo "Then your MR from dev → main should work without conflicts."
else
echo ""
echo "⚠ Conflicts detected! You need to resolve them manually:"
echo ""
echo "Conflicting files:"
git diff --name-only --diff-filter=U
echo ""
echo "To resolve:"
echo " 1. Edit the conflicting files"
echo " 2. git add <resolved-files>"
echo " 3. git commit -m 'Merge main into dev: resolve conflicts'"
echo " 4. git push gitea dev"
echo ""
echo "Then your MR from dev → main should work."
fi

View File

@ -1,5 +1,31 @@
import type { NextConfig } from "next";
/**
* Next.js Configuration
*
* Configuration decisions:
*
* 1. Image Optimization:
* - Enabled (unoptimized: false) for better performance and bandwidth usage
* - Remote patterns allow all hosts (http/https) - this is permissive but necessary
* for the photo guessing game where users may upload images from any URL.
* - Consider restricting in production if security is a concern, but this would
* limit functionality for URL-based photo uploads.
*
* 2. Turbopack Configuration:
* - Currently configured but not actively used (dev script uses --webpack flag)
* - Kept for future migration to Turbopack when stable
* - Can be removed if not planning to use Turbopack
*
* 3. Webpack Configuration:
* - Prisma client is externalized on server-side to prevent bundling issues
* - This is necessary because Prisma generates client code that shouldn't be bundled
* - Required for proper Prisma functionality in Next.js
*
* 4. Page Extensions:
* - Only processes TypeScript/JavaScript files (ts, tsx, js, jsx)
* - Prevents processing of other file types as pages
*/
const nextConfig: NextConfig = {
// Only process specific file extensions
pageExtensions: ["ts", "tsx", "js", "jsx"],
@ -16,10 +42,15 @@ const nextConfig: NextConfig = {
hostname: "**",
},
],
unoptimized: false, // Enable optimization for better performance
// Enable optimization for better performance and bandwidth usage
// Note: Remote patterns are permissive to allow URL-based photo uploads
// Consider restricting in production if security is a concern
unoptimized: false,
},
// Configure Turbopack
// Configure Turbopack (currently not used - dev script uses --webpack)
// Kept for future migration when Turbopack is stable
// Can be removed if not planning to use Turbopack
turbopack: {
resolveExtensions: [
".tsx",
@ -38,6 +69,7 @@ const nextConfig: NextConfig = {
},
// Webpack configuration to externalize Prisma
// Required: Prisma client must be externalized on server-side to prevent bundling issues
webpack: (config, { isServer }) => {
if (isServer) {
config.externals = config.externals || [];

View File

@ -1,6 +1,7 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { getToken } from "next-auth/jwt"
import { logActivity } from "./lib/activity-log"
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname
@ -11,30 +12,39 @@ export async function proxy(request: NextRequest) {
}
// Get token (works in Edge runtime)
// Explicitly specify the cookie name to match NextAuth config
const cookieName = "__Secure-authjs.session-token"
// For HTTPS, NextAuth adds __Secure- prefix automatically
// getToken should handle the prefix, but we specify the base name
const isHttps = request.url.startsWith("https://")
const cookieName = isHttps ? `__Secure-authjs.session-token` : `authjs.session-token`
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
cookieName: cookieName
cookieName: cookieName,
})
// User activity logging - track all page visits and API calls
const timestamp = new Date().toISOString()
const userAgent = request.headers.get("user-agent") || "unknown"
const ip = request.headers.get("x-forwarded-for") ||
request.headers.get("x-real-ip") ||
"unknown"
// Uses structured logging with log levels (INFO level, can be filtered)
const user = token ? {
id: token.id as string,
email: token.email as string,
role: token.role as string,
} : null
const referer = request.headers.get("referer") || "direct"
const method = request.method
const userAgent = request.headers.get("user-agent") || "unknown"
if (token) {
// Log authenticated user activity
console.log(`[ACTIVITY] ${timestamp} | ${method} ${pathname} | User: ${token.email} (${token.role}) | IP: ${ip} | Referer: ${referer}`)
} else {
// Log unauthenticated access attempts
console.log(`[ACTIVITY] ${timestamp} | ${method} ${pathname} | User: UNAUTHENTICATED | IP: ${ip} | Referer: ${referer} | UA: ${userAgent.substring(0, 100)}`)
}
logActivity(
token ? "PAGE_VIEW" : "UNAUTHENTICATED_ACCESS",
pathname,
request.method,
user,
{
referer,
userAgent: userAgent.substring(0, 100), // Limit length
},
request
)
// Protected routes - require authentication
if (!token) {

96
rebuild.sh Executable file
View File

@ -0,0 +1,96 @@
#!/bin/bash
# Complete clean rebuild and start script for MirrorMatch
# Usage: ./rebuild.sh [dev|prod]
# dev - Development mode (hot reload, foreground)
# prod - Production mode (optimized, background with logging)
set -e
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR" || {
echo "Error: Could not change to script directory: $SCRIPT_DIR"
exit 1
}
MODE=${1:-prod}
echo "========================================="
echo "MirrorMatch - Clean Rebuild & Start"
echo "Mode: ${MODE}"
echo "Working directory: $SCRIPT_DIR"
echo "========================================="
# Step 1: Kill everything
echo ""
echo "Step 1: Killing all processes..."
sudo fuser -k 3000/tcp 2>/dev/null || true
killall -9 node 2>/dev/null || true
pkill -f "next" 2>/dev/null || true
sleep 2
echo "✓ All processes killed"
# Step 2: Free ports
echo ""
echo "Step 2: Freeing ports..."
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
lsof -ti:3003 | xargs kill -9 2>/dev/null || true
sleep 1
echo "✓ Ports freed"
# Step 3: Clean build artifacts
echo ""
echo "Step 3: Cleaning build artifacts..."
rm -rf .next node_modules/.cache .next/cache .next/dev/lock 2>/dev/null || true
echo "✓ Build artifacts cleaned"
# Step 4: Rebuild (only for production)
if [ "$MODE" = "prod" ]; then
echo ""
echo "Step 4: Rebuilding application..."
npm run build
echo "✓ Build complete"
fi
# Step 5: Start server
echo ""
echo "Step 5: Starting server..."
echo "========================================="
if [ "$MODE" = "dev" ]; then
echo "Development mode - logs will appear below:"
echo "Press Ctrl+C to stop"
echo "========================================="
echo ""
export NODE_ENV=development
unset AUTH_TRUST_HOST
npm run dev
else
LOG_FILE="${LOG_FILE:-/tmp/mirrormatch-server.log}"
echo "Production mode - server running in background"
echo "View logs: tail -f $LOG_FILE"
echo "========================================="
echo ""
export NODE_ENV=production
unset AUTH_TRUST_HOST
npm run start > "$LOG_FILE" 2>&1 &
SERVER_PID=$!
echo "Server PID: $SERVER_PID"
echo "Log file: $LOG_FILE"
echo ""
# Wait for server to start (check log file for "Ready" message or check HTTP)
echo "Waiting for server to start..."
for i in {1..30}; do
if curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 2>/dev/null | grep -q "200\|307\|404"; then
echo "✓ Server is running on http://localhost:3000"
break
fi
if [ $i -eq 30 ]; then
echo "⚠ Server may still be starting. Check logs: tail -f $LOG_FILE"
echo " Or check if process is running: ps -p $SERVER_PID"
else
sleep 1
fi
done
fi

82
watch-activity.sh Normal file → Executable file
View File

@ -1,10 +1,92 @@
#!/bin/bash
# Watch user activity logs in real-time
<<<<<<< HEAD
#
# This script monitors logs for MirrorMatch activity.
# It can watch systemd journal logs OR application log files.
#
# Usage: ./watch-activity.sh [logfile]
# - No argument: Try systemd journal (requires systemd service)
# - With argument: Watch specified log file (e.g., ./watch-activity.sh /tmp/mirrormatch-server.log)
#
# For local development:
# - If running via rebuild.sh: ./watch-activity.sh /tmp/mirrormatch-server.log
# - If running via npm run dev: ./watch-activity.sh app.log (if you redirect output)
# - If running as systemd service: ./watch-activity.sh (uses journalctl)
LOG_FILE="${1:-}"
=======
# Usage: ./watch-activity.sh
>>>>>>> gitea/main
echo "Watching user activity logs..."
echo "Press Ctrl+C to stop"
echo ""
<<<<<<< HEAD
if [ -n "$LOG_FILE" ]; then
# Watch log file
if [ ! -f "$LOG_FILE" ]; then
echo "⚠ Warning: Log file '$LOG_FILE' does not exist yet."
echo " Waiting for it to be created (will wait up to 30 seconds)..."
echo ""
# Wait for file to be created
for i in {1..30}; do
if [ -f "$LOG_FILE" ]; then
echo "✓ Log file created"
break
fi
sleep 1
done
if [ ! -f "$LOG_FILE" ]; then
echo "❌ Log file still doesn't exist after 30 seconds"
echo " Make sure the server is running: ./rebuild.sh prod"
exit 1
fi
fi
tail -f "$LOG_FILE" 2>/dev/null | grep -E "\[ACTIVITY\]|\[PHOTO_UPLOAD\]|\[GUESS_SUBMIT\]|Activity:|INFO.*Activity:" || {
echo "⚠ No activity logs found. Make sure:"
echo " 1. Server is running"
echo " 2. LOG_LEVEL is set to INFO or DEBUG"
echo " 3. Users are actually using the site"
}
elif systemctl is-active --quiet app-backend 2>/dev/null; then
# Use systemd journal if service is active
echo "Using systemd journal (app-backend service)"
echo ""
sudo journalctl -u app-backend -f | grep -E "\[ACTIVITY\]|\[PHOTO_UPLOAD\]|\[GUESS_SUBMIT\]|Activity:|INFO.*Activity:"
else
# Try common log file locations
echo "Systemd service not found. Trying common log file locations..."
echo ""
LOG_FILES=(
"/tmp/mirrormatch-server.log"
"app.log"
"./app.log"
"/var/log/mirrormatch/app.log"
)
FOUND=false
for log in "${LOG_FILES[@]}"; do
if [ -f "$log" ]; then
echo "Found log file: $log"
echo ""
tail -f "$log" | grep -E "\[ACTIVITY\]|\[PHOTO_UPLOAD\]|\[GUESS_SUBMIT\]|Activity:|INFO.*Activity:"
FOUND=true
break
fi
done
if [ "$FOUND" = false ]; then
echo "❌ No log file found. Options:"
echo " 1. Specify log file: ./watch-activity.sh /path/to/logfile"
echo " 2. If using rebuild.sh, logs go to: /tmp/mirrormatch-server.log"
echo " 3. If using systemd, ensure app-backend service is running"
exit 1
fi
fi
=======
# Watch for activity logs (ACTIVITY, PHOTO_UPLOAD, GUESS_SUBMIT)
sudo journalctl -u app-backend -f | grep -E "\[ACTIVITY\]|\[PHOTO_UPLOAD\]|\[GUESS_SUBMIT\]"
>>>>>>> gitea/main