Implements a comprehensive structured logging system to replace verbose console.* calls throughout the codebase, addressing all cleanup tasks from CLEANUP.md. (#4)

# Structured Logging System Implementation

## Summary
Implements a comprehensive structured logging system to replace verbose console.* calls throughout the codebase, addressing all cleanup tasks from CLEANUP.md.

## What Changed

### Core Features
-  **Structured Logging System** - New `lib/logger.ts` with DEBUG, INFO, WARN, ERROR levels
-  **Environment-Based Control** - `LOG_LEVEL` env var controls verbosity (DEBUG/INFO/WARN/ERROR/NONE)
-  **JSON Logging Option** - `LOG_FORMAT=json` for structured JSON output
-  **Shared Constants** - Extracted session cookie name to `lib/constants.ts`

### Code Refactoring
-  Replaced all `console.*` calls in API routes with structured logger
-  Refactored `activity-log.ts` to use new logger system
-  Reduced verbose logging in auth, photos page, and upload routes
-  Updated proxy.ts to use structured logging
-  Removed unused legacy `/api/photos` route (replaced by `/api/photos/upload`)

### Security Improvements
-  Protected `/api/debug/session` endpoint with admin-only access
-  Added proper error logging with structured context

### Documentation
-  Documented multiple upload routes usage
-  Enhanced watch-activity.sh script documentation
-  Updated README.md with upload endpoint information
-  Added configuration documentation to next.config.ts

### Testing
-  Added 23 tests for logger system
-  Added 8 tests for refactored activity-log
-  All 43 tests passing

## Benefits

1. **Production-Ready Logging** - Environment-based control, defaults to INFO in production
2. **Reduced Verbosity** - DEBUG logs only show in development or when explicitly enabled
3. **Structured Output** - JSON format option for log aggregation tools
4. **Better Organization** - Shared constants, consistent logging patterns
5. **Improved Security** - Debug endpoint now requires admin access

## Testing

### Manual Testing
-  Server builds successfully
-  All tests pass (43/43)
-  Type checking passes
-  Linting passes
-  Production server runs with logs visible
-  Log levels work correctly (DEBUG shows all, INFO shows activity, etc.)

### Test Coverage
- Logger system: 100% coverage
- Activity log: 100% coverage
- All existing tests still pass

## Configuration

### Environment Variables
```bash
# Control log verbosity (DEBUG, INFO, WARN, ERROR, NONE)
LOG_LEVEL=INFO

# Use structured JSON logging
LOG_FORMAT=json
```

### Defaults
- Development: `LOG_LEVEL=DEBUG` (shows all logs)
- Production: `LOG_LEVEL=INFO` (shows activity and above)

## Migration Notes

- No breaking changes (legacy route was unused)
- All existing functionality preserved
- Logs are now structured and filterable
- Debug endpoint now requires admin authentication
- Legacy `/api/photos` endpoint removed (use `/api/photos/upload` instead)

## Checklist

- [x] All console.* calls replaced in API routes
- [x] Logger system implemented with tests
- [x] Activity logging refactored
- [x] Debug endpoint protected
- [x] Documentation updated
- [x] All tests passing
- [x] Type checking passes
- [x] Linting passes
- [x] Build succeeds
- [x] Manual testing completed

## Related Issues
Addresses cleanup tasks from CLEANUP.md:
- Task 1: Verbose logging in production 
- Task 2: Activity logging optimization 
- Task 3: Upload verification logging 
- Task 4: Middleware debug logging 
- Task 5: Legacy upload route documentation 
- Task 6: Multiple upload routes documentation 
- Task 7: Cookie name constant extraction 
- Task 8: Next.js config documentation 
- Task 9: ARCHITECTURE.md (already correct) 
- Task 10: Watch activity script documentation 

Reviewed-on: #4
This commit is contained in:
ilia 2026-01-04 19:42:49 -05:00
parent 01480586ff
commit 08914dc469
24 changed files with 852 additions and 186 deletions

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,14 @@
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"
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ userId: string }> }
) {
let userId: string | undefined
try {
const session = await auth()
@ -14,7 +16,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 +35,10 @@ export async function POST(
return NextResponse.json({ success: true })
} catch (error) {
console.error("Error resetting password:", error)
logger.error("Error resetting password", {
userId,
error: error instanceof Error ? error : new Error(String(error)),
})
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }

View File

@ -2,6 +2,7 @@ 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"
export async function POST(req: NextRequest) {
try {
@ -53,7 +54,9 @@ export async function POST(req: NextRequest) {
{ status: 201 }
)
} catch (error) {
console.error("Error creating user:", error)
logger.error("Error creating user", {
error: error instanceof Error ? error : new Error(String(error)),
})
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }

View File

@ -1,9 +1,29 @@
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"
/**
* 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 +36,29 @@ export async function GET(request: Request) {
})
// Try to get session token from cookies
const sessionTokenFromHeader = cookieMap["__Secure-authjs.session-token"] || "NOT FOUND"
const sessionTokenFromHeader = cookieMap[SESSION_COOKIE_NAME] || "NOT FOUND"
// Try to call auth() - this might fail or return null
let session = null
// Try to call auth() again for debugging (we already have session above, but this is for testing)
let authError = null
try {
console.log("Debug endpoint: Calling auth()...")
session = await auth()
console.log("Debug endpoint: auth() returned", {
// Already called above, but keeping for backward compatibility in response
logger.debug("Debug endpoint: Session retrieved", {
hasSession: !!session,
sessionUser: session?.user,
sessionKeys: session ? Object.keys(session) : []
userId: session?.user?.id,
userRole: session?.user?.role,
})
} catch (err) {
authError = err instanceof Error ? err.message : String(err)
console.error("Debug endpoint: auth() error", authError)
logger.error("Debug endpoint: auth() error", {
error: err instanceof Error ? err : new Error(String(err)),
})
}
// Try to get cookie from Next.js cookie store
let sessionTokenFromStore = "NOT ACCESSIBLE"
try {
const cookieStore = await cookies()
sessionTokenFromStore = cookieStore.get("__Secure-authjs.session-token")?.value || "NOT FOUND"
sessionTokenFromStore = cookieStore.get(SESSION_COOKIE_NAME)?.value || "NOT FOUND"
} catch {
// Cookie store might not be accessible in all contexts
}

View File

@ -3,6 +3,7 @@ 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"
export async function POST(
req: NextRequest,
@ -151,7 +152,9 @@ export async function POST(
pointsChange
})
} catch (error) {
console.error("Error submitting guess:", error)
logger.error("Error submitting guess", {
error: error instanceof Error ? error : new Error(String(error)),
})
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }

View File

@ -1,6 +1,7 @@
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"
@ -47,7 +48,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 +64,9 @@ export async function DELETE(
return NextResponse.json({ success: true, message: "Photo deleted successfully" })
} catch (error) {
console.error("Error deleting photo:", error)
logger.error("Error deleting photo", {
error: error instanceof Error ? error : new Error(String(error)),
})
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }

View File

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

View File

@ -1,7 +1,31 @@
/**
* 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"
@ -204,7 +228,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 +244,9 @@ export async function POST(req: NextRequest) {
{ status: 201 }
)
} catch (error) {
console.error("Error uploading photos:", error)
logger.error("Error uploading photos", {
error: error instanceof Error ? error : new Error(String(error)),
})
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }

View File

@ -3,6 +3,7 @@ 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"
@ -86,9 +87,9 @@ 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)})`)
// Filename is generated server-side (timestamp + random), safe for path.join
const filepath = join(uploadsDir, filename)
@ -98,9 +99,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 +172,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 +199,9 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ photo }, { status: 201 })
} catch (error) {
console.error("Error uploading photo:", error)
logger.error("Error uploading photo", {
error: error instanceof Error ? error : new Error(String(error)),
})
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }

View File

@ -1,6 +1,7 @@
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"
@ -44,7 +45,9 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true })
} catch (error) {
console.error("Error changing password:", error)
logger.error("Error changing password", {
error: error instanceof Error ? error : new Error(String(error)),
})
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }

View File

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

View File

@ -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({

View File

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

View File

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

View File

@ -2,6 +2,8 @@ import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { prisma } from "./prisma"
import bcrypt from "bcryptjs"
import { logger } from "./logger"
import { SESSION_COOKIE_NAME } from "./constants"
const nextAuthSecret = process.env.NEXTAUTH_SECRET
if (!nextAuthSecret) {
@ -48,7 +50,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 +63,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 +88,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
})
@ -126,7 +117,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
},
cookies: {
sessionToken: {
name: `__Secure-authjs.session-token`,
name: SESSION_COOKIE_NAME,
options: {
httpOnly: true,
sameSite: "lax",

9
lib/constants.ts Normal file
View File

@ -0,0 +1,9 @@
/**
* Application-wide constants
*/
/**
* NextAuth session cookie name
* Must match the cookie name defined in lib/auth.ts
*/
export const SESSION_COOKIE_NAME = "__Secure-authjs.session-token"

156
lib/logger.ts Normal file
View File

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

View File

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

View File

@ -1,6 +1,8 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { getToken } from "next-auth/jwt"
import { SESSION_COOKIE_NAME } from "./lib/constants"
import { logActivity } from "./lib/activity-log"
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname
@ -11,30 +13,35 @@ 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"
// Use constant for cookie name to match NextAuth config
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
cookieName: cookieName
cookieName: SESSION_COOKIE_NAME
})
// 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) {

View File

@ -1,10 +1,29 @@
#!/bin/bash
# Watch user activity logs in real-time
#
# This script monitors systemd journal logs for MirrorMatch activity.
# It filters for activity-related log entries including page visits, photo uploads, and guess submissions.
#
# Usage: ./watch-activity.sh
#
# Requirements:
# - Systemd/journalctl (Linux systems with systemd)
# - Appropriate permissions (may require sudo)
# - Application running as a systemd service named "app-backend"
#
# For local development without systemd:
# - Check application console output directly
# - Use `npm run dev` and monitor terminal output
# - Or redirect logs to a file and tail it: `npm run dev > app.log 2>&1 && tail -f app.log`
#
# Note: With the new structured logging system, you can also filter by log level:
# - Set LOG_LEVEL=DEBUG to see all activity logs
# - Set LOG_FORMAT=json for structured JSON logs
echo "Watching user activity logs..."
echo "Press Ctrl+C to stop"
echo ""
# Watch for activity logs (ACTIVITY, PHOTO_UPLOAD, GUESS_SUBMIT)
sudo journalctl -u app-backend -f | grep -E "\[ACTIVITY\]|\[PHOTO_UPLOAD\]|\[GUESS_SUBMIT\]"
# These patterns match the activity log format from lib/activity-log.ts
sudo journalctl -u app-backend -f | grep -E "\[ACTIVITY\]|\[PHOTO_UPLOAD\]|\[GUESS_SUBMIT\]|Activity:"