From df865dca4158375cc4dbcb1ac729a6da4663820c Mon Sep 17 00:00:00 2001 From: ilia Date: Mon, 5 Jan 2026 19:42:46 -0500 Subject: [PATCH] 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) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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: https://git.levkin.ca/ilia/mirror_match/pulls/5 --- .gitignore | 3 + ARCHITECTURE.md | 6 +- IMPROVEMENTS.md | 335 ++++++++++++++++++ README.md | 5 + REBUILD.md | 69 ++++ __tests__/components/HelpModal.test.tsx | 117 ++++++ __tests__/lib/activity-log.test.ts | 209 +++++++++++ __tests__/lib/logger.test.ts | 210 +++++++++++ .../users/[userId]/reset-password/route.ts | 12 +- app/api/admin/users/route.ts | 8 +- app/api/auth/[...nextauth]/route.ts | 4 + app/api/debug/session/route.ts | 43 ++- app/api/photos/[photoId]/guess/route.ts | 8 +- app/api/photos/[photoId]/route.ts | 13 +- app/api/photos/route.ts | 91 ----- app/api/photos/upload-multiple/route.ts | 37 +- app/api/photos/upload/route.ts | 26 +- app/api/profile/change-password/route.ts | 8 +- app/api/uploads/[filename]/route.ts | 15 +- app/layout.tsx | 2 + app/login/page.tsx | 14 +- app/photos/page.tsx | 25 +- components/HelpModal.tsx | 285 +++++++++++++++ deploy-and-watch.sh | 45 +++ env.example | 6 + lib/activity-log.ts | 36 +- lib/auth.ts | 102 ++++-- lib/constants.ts | 10 + lib/logger.ts | 156 ++++++++ merge-main-into-dev.sh | 51 +++ next.config.ts | 36 +- proxy.ts | 42 ++- rebuild.sh | 96 +++++ watch-activity.sh | 82 +++++ 34 files changed, 2006 insertions(+), 201 deletions(-) create mode 100644 IMPROVEMENTS.md create mode 100644 REBUILD.md create mode 100644 __tests__/components/HelpModal.test.tsx create mode 100644 __tests__/lib/activity-log.test.ts create mode 100644 __tests__/lib/logger.test.ts delete mode 100644 app/api/photos/route.ts create mode 100644 components/HelpModal.tsx create mode 100755 deploy-and-watch.sh create mode 100644 lib/constants.ts create mode 100644 lib/logger.ts create mode 100755 merge-main-into-dev.sh create mode 100755 rebuild.sh mode change 100644 => 100755 watch-activity.sh diff --git a/.gitignore b/.gitignore index b7a560c..4215494 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ next-env.d.ts # Test coverage /coverage /.nyc_output + +# Application logs +*.log diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 389ea92..d48bfae 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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:** diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 0000000..6133715 --- /dev/null +++ b/IMPROVEMENTS.md @@ -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. diff --git a/README.md b/README.md index 649043d..daf4387 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/REBUILD.md b/REBUILD.md new file mode 100644 index 0000000..d410fd5 --- /dev/null +++ b/REBUILD.md @@ -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 +``` diff --git a/__tests__/components/HelpModal.test.tsx b/__tests__/components/HelpModal.test.tsx new file mode 100644 index 0000000..633d127 --- /dev/null +++ b/__tests__/components/HelpModal.test.tsx @@ -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() + expect(screen.queryByText("Welcome to MirrorMatch")).not.toBeInTheDocument() + }) + + it("should open when Shift+? is pressed", () => { + render() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + }) +}) diff --git a/__tests__/lib/activity-log.test.ts b/__tests__/lib/activity-log.test.ts new file mode 100644 index 0000000..7290491 --- /dev/null +++ b/__tests__/lib/activity-log.test.ts @@ -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 = {}): 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(); + }); + }); +}); diff --git a/__tests__/lib/logger.test.ts b/__tests__/lib/logger.test.ts new file mode 100644 index 0000000..4dea221 --- /dev/null +++ b/__tests__/lib/logger.test.ts @@ -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(); + }); + }); +}); diff --git a/app/api/admin/users/[userId]/reset-password/route.ts b/app/api/admin/users/[userId]/reset-password/route.ts index 6cf4bc4..53fc878 100644 --- a/app/api/admin/users/[userId]/reset-password/route.ts +++ b/app/api/admin/users/[userId]/reset-password/route.ts @@ -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 } diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index 3288412..7bb01c4 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -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 } diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 866b2be..f312a22 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -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 diff --git a/app/api/debug/session/route.ts b/app/api/debug/session/route.ts index 2f9ed46..ac1416b 100644 --- a/app/api/debug/session/route.ts +++ b/app/api/debug/session/route.ts @@ -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 } diff --git a/app/api/photos/[photoId]/guess/route.ts b/app/api/photos/[photoId]/guess/route.ts index 0db93d9..53540e7 100644 --- a/app/api/photos/[photoId]/guess/route.ts +++ b/app/api/photos/[photoId]/guess/route.ts @@ -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 } diff --git a/app/api/photos/[photoId]/route.ts b/app/api/photos/[photoId]/route.ts index efac356..86517b6 100644 --- a/app/api/photos/[photoId]/route.ts +++ b/app/api/photos/[photoId]/route.ts @@ -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 } diff --git a/app/api/photos/route.ts b/app/api/photos/route.ts deleted file mode 100644 index e89474b..0000000 --- a/app/api/photos/route.ts +++ /dev/null @@ -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 } - ) - } -} diff --git a/app/api/photos/upload-multiple/route.ts b/app/api/photos/upload-multiple/route.ts index 207f848..629a17a 100644 --- a/app/api/photos/upload-multiple/route.ts +++ b/app/api/photos/upload-multiple/route.ts @@ -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 } diff --git a/app/api/photos/upload/route.ts b/app/api/photos/upload/route.ts index 87d8b48..2fdc006 100644 --- a/app/api/photos/upload/route.ts +++ b/app/api/photos/upload/route.ts @@ -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 } diff --git a/app/api/profile/change-password/route.ts b/app/api/profile/change-password/route.ts index 5eb75e3..753450f 100644 --- a/app/api/profile/change-password/route.ts +++ b/app/api/profile/change-password/route.ts @@ -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 } diff --git a/app/api/uploads/[filename]/route.ts b/app/api/uploads/[filename]/route.ts index 7fec8a4..5133aa0 100644 --- a/app/api/uploads/[filename]/route.ts +++ b/app/api/uploads/[filename]/route.ts @@ -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 } diff --git a/app/layout.tsx b/app/layout.tsx index acac771..bee8b53 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({
{children}
+
diff --git a/app/login/page.tsx b/app/login/page.tsx index b8711df..daf87f2 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -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.") } diff --git a/app/photos/page.tsx b/app/photos/page.tsx index d1a69ec..d5941ba 100644 --- a/app/photos/page.tsx +++ b/app/photos/page.tsx @@ -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({ diff --git a/components/HelpModal.tsx b/components/HelpModal.tsx new file mode 100644 index 0000000..bb30cf0 --- /dev/null +++ b/components/HelpModal.tsx @@ -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 */} +
setIsOpen(false)} + > + {/* Modal */} +
e.stopPropagation()} + > + {/* Header */} +
+

Welcome to MirrorMatch

+ +
+ + {/* Content */} +
+ {/* What is MirrorMatch */} +
+

+ + + + What is MirrorMatch? +

+

+ 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! +

+
+ + {/* How to Play */} +
+

+ + + + + How to Play +

+
    +
  1. + Upload Photos: Go to the Upload page and add photos with answer + names. You can upload files or use image URLs. +
  2. +
  3. + Guess Photos: 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. +
  4. +
  5. + Earn Points: Get points for each correct guess! The more you + guess correctly, the higher you'll climb on the leaderboard. +
  6. +
  7. + Compete: Check the Leaderboard to see how you rank against other + players. +
  8. +
+
+ + {/* Features */} +
+

+ + + + Key Features +

+
    +
  • + + + Photo Upload: Share photos via file upload or URL + +
  • +
  • + + + Guessing System: Submit guesses and get instant feedback + +
  • +
  • + + + Email Notifications: Get notified when new photos are + uploaded + +
  • +
  • + + + Leaderboard: Track rankings and compete with others + +
  • +
  • + + + Profile Management: View your points and manage your account + +
  • +
+
+ + {/* Keyboard Shortcuts */} +
+

+ + + + Keyboard Shortcuts +

+
+
+ + + Shift + + {" + "} + + ? + + + Open/Close this help window +
+
+ + + Esc + + + Close help window +
+
+
+ + {/* Tips */} +
+

+ + + + Tips +

+
    +
  • + 💡 + Guesses are case-insensitive, so don't worry about capitalization +
  • +
  • + 💡 + You can't guess your own photos, but you can still view them +
  • +
  • + 💡 + + You'll receive email notifications when other users upload new photos + +
  • +
  • + 💡 + Check the leaderboard regularly to see your ranking +
  • +
+
+
+ + {/* Footer */} +
+

+ Press Esc or click outside to close +

+
+
+
+ + ) +} diff --git a/deploy-and-watch.sh b/deploy-and-watch.sh new file mode 100755 index 0000000..905b730 --- /dev/null +++ b/deploy-and-watch.sh @@ -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" diff --git a/env.example b/env.example index 4322a8d..fa28f5b 100644 --- a/env.example +++ b/env.example @@ -15,3 +15,9 @@ SMTP_FROM="MirrorMatch " # 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 diff --git a/lib/activity-log.ts b/lib/activity-log.ts index a4be39c..ed56b2c 100644 --- a/lib/activity-log.ts +++ b/lib/activity-log.ts @@ -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 } +/** + * 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, 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 } diff --git a/lib/auth.ts b/lib/auth.ts index 3486012..d5984de 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -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(), }) diff --git a/lib/constants.ts b/lib/constants.ts new file mode 100644 index 0000000..4ff933d --- /dev/null +++ b/lib/constants.ts @@ -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" diff --git a/lib/logger.ts b/lib/logger.ts new file mode 100644 index 0000000..0a2448f --- /dev/null +++ b/lib/logger.ts @@ -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 = { + 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 }; diff --git a/merge-main-into-dev.sh b/merge-main-into-dev.sh new file mode 100755 index 0000000..f802b1f --- /dev/null +++ b/merge-main-into-dev.sh @@ -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 " + 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 diff --git a/next.config.ts b/next.config.ts index 6e9208d..fed0036 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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 || []; diff --git a/proxy.ts b/proxy.ts index 9595fe8..891dbfd 100644 --- a/proxy.ts +++ b/proxy.ts @@ -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) { diff --git a/rebuild.sh b/rebuild.sh new file mode 100755 index 0000000..e2cb445 --- /dev/null +++ b/rebuild.sh @@ -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 diff --git a/watch-activity.sh b/watch-activity.sh old mode 100644 new mode 100755 index cb24fb3..79bc6db --- a/watch-activity.sh +++ b/watch-activity.sh @@ -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