Implements a comprehensive structured logging system to replace verbose console.* calls throughout the codebase, addressing all cleanup tasks from CLEANUP.md. #4
3
.gitignore
vendored
3
.gitignore
vendored
@ -50,3 +50,6 @@ next-env.d.ts
|
||||
# Test coverage
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# Application logs
|
||||
*.log
|
||||
|
||||
@ -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:**
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
209
__tests__/lib/activity-log.test.ts
Normal file
209
__tests__/lib/activity-log.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
210
__tests__/lib/logger.test.ts
Normal file
210
__tests__/lib/logger.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 }
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
39
lib/auth.ts
39
lib/auth.ts
@ -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
9
lib/constants.ts
Normal 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
156
lib/logger.ts
Normal 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 };
|
||||
@ -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 || [];
|
||||
|
||||
41
proxy.ts
41
proxy.ts
@ -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"
|
||||
const referer = request.headers.get("referer") || "direct"
|
||||
const method = request.method
|
||||
// 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
|
||||
|
||||
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)}`)
|
||||
}
|
||||
const referer = request.headers.get("referer") || "direct"
|
||||
const userAgent = request.headers.get("user-agent") || "unknown"
|
||||
|
||||
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) {
|
||||
|
||||
@ -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:"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user