From 91adbab487f96b0da2c2b36d45bd4bf47e8ff576 Mon Sep 17 00:00:00 2001 From: ilia Date: Sun, 4 Jan 2026 14:29:17 -0500 Subject: [PATCH] feat: Implement user activity logging and upload handling - Enhanced the proxy function to log user activity for both authenticated and unauthenticated requests, capturing details such as IP address, user agent, and referer. - Introduced a new utility for logging activities, allowing for structured tracking of user actions across various routes. - Updated photo upload and guess submission routes to log relevant user activity, improving visibility into user interactions. - Added a script to watch user activity logs in real-time for easier monitoring. --- app/api/photos/[photoId]/guess/route.ts | 16 +++++++ app/api/photos/upload-multiple/route.ts | 2 +- app/api/photos/upload/route.ts | 32 +++++++++++++- app/api/uploads/[filename]/route.ts | 58 +++++++++++++++++++++++++ lib/activity-log.ts | 52 ++++++++++++++++++++++ proxy.ts | 32 ++++++-------- watch-activity.sh | 10 +++++ 7 files changed, 180 insertions(+), 22 deletions(-) create mode 100644 app/api/uploads/[filename]/route.ts create mode 100644 lib/activity-log.ts create mode 100644 watch-activity.sh diff --git a/app/api/photos/[photoId]/guess/route.ts b/app/api/photos/[photoId]/guess/route.ts index c0445cd..0db93d9 100644 --- a/app/api/photos/[photoId]/guess/route.ts +++ b/app/api/photos/[photoId]/guess/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server" import { auth } from "@/lib/auth" import { prisma } from "@/lib/prisma" import { normalizeString } from "@/lib/utils" +import { logActivity } from "@/lib/activity-log" export async function POST( req: NextRequest, @@ -129,6 +130,21 @@ export async function POST( } } + // Log guess activity + logActivity( + "GUESS_SUBMIT", + `/api/photos/${photoId}/guess`, + "POST", + session.user, + { + photoId, + guessText: guess.guessText.substring(0, 50), // Truncate for privacy + correct: isCorrect, + pointsChange + }, + req + ) + return NextResponse.json({ guess, correct: isCorrect, diff --git a/app/api/photos/upload-multiple/route.ts b/app/api/photos/upload-multiple/route.ts index 489535e..207f848 100644 --- a/app/api/photos/upload-multiple/route.ts +++ b/app/api/photos/upload-multiple/route.ts @@ -133,7 +133,7 @@ export async function POST(req: NextRequest) { const filepath = join(uploadsDir, filename) await writeFile(filepath, buffer) - photoUrl = `/uploads/${filename}` + photoUrl = `/api/uploads/${filename}` } else if (url) { // Handle URL upload photoUrl = url diff --git a/app/api/photos/upload/route.ts b/app/api/photos/upload/route.ts index 76afdc2..87d8b48 100644 --- a/app/api/photos/upload/route.ts +++ b/app/api/photos/upload/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server" import { auth } from "@/lib/auth" import { prisma } from "@/lib/prisma" import { sendNewPhotoEmail } from "@/lib/email" +import { logActivity } from "@/lib/activity-log" import { writeFile } from "fs/promises" import { join } from "path" import { existsSync, mkdirSync } from "fs" @@ -85,14 +86,26 @@ 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}`) } + 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) await writeFile(filepath, buffer) + + // Verify file was written successfully + const { access } = await import("fs/promises") + try { + await access(filepath) + console.log(`[UPLOAD] File saved successfully: ${filepath}`) + } catch (error) { + console.error(`[UPLOAD] File write verification failed: ${filepath}`, error) + throw new Error("Failed to save file to disk") + } - // Set URL to the uploaded file - photoUrl = `/uploads/${filename}` + // Set URL to the uploaded file - use API route to ensure it's accessible + photoUrl = `/api/uploads/${filename}` } else { // Handle URL upload (fallback) const url = formData.get("url") as string | null @@ -159,6 +172,21 @@ export async function POST(req: NextRequest) { ) ) + // Log photo upload activity + logActivity( + "PHOTO_UPLOAD", + "/api/photos/upload", + "POST", + session.user, + { + photoId: photo.id, + answerName: photo.answerName, + points: photo.points, + filename: photoUrl.split("/").pop() + }, + req + ) + return NextResponse.json({ photo }, { status: 201 }) } catch (error) { console.error("Error uploading photo:", error) diff --git a/app/api/uploads/[filename]/route.ts b/app/api/uploads/[filename]/route.ts new file mode 100644 index 0000000..7fec8a4 --- /dev/null +++ b/app/api/uploads/[filename]/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server" +import { readFile } from "fs/promises" +import { join } from "path" +import { existsSync } from "fs" + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ filename: string }> } +) { + try { + const { filename } = await params + + // Sanitize filename - only allow alphanumeric, dots, hyphens + if (!/^[a-zA-Z0-9._-]+$/.test(filename)) { + return NextResponse.json({ error: "Invalid filename" }, { status: 400 }) + } + + // Get the uploads directory + const uploadsDir = join(process.cwd(), "public", "uploads") + const filepath = join(uploadsDir, filename) + + // Security: ensure file is within uploads directory (prevent path traversal) + if (!filepath.startsWith(uploadsDir)) { + return NextResponse.json({ error: "Invalid path" }, { status: 400 }) + } + + // Check if file exists + if (!existsSync(filepath)) { + console.error(`[UPLOAD] File not found: ${filepath} (cwd: ${process.cwd()})`) + return NextResponse.json({ error: "File not found" }, { status: 404 }) + } + + // Read and serve the file + const fileBuffer = await readFile(filepath) + + // Determine content type from extension + const ext = filename.split(".").pop()?.toLowerCase() + const contentType = + ext === "jpg" || ext === "jpeg" ? "image/jpeg" : + ext === "png" ? "image/png" : + ext === "gif" ? "image/gif" : + ext === "webp" ? "image/webp" : + "application/octet-stream" + + return new NextResponse(fileBuffer, { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", + }, + }) + } catch (error) { + console.error("[UPLOAD] Error serving file:", error) + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ) + } +} diff --git a/lib/activity-log.ts b/lib/activity-log.ts new file mode 100644 index 0000000..f4ffacc --- /dev/null +++ b/lib/activity-log.ts @@ -0,0 +1,52 @@ +/** + * Activity logging utility for tracking user actions + */ + +export interface ActivityLog { + timestamp: string + userId?: string + userEmail?: string + userRole?: string + action: string + path: string + method: string + ip?: string + details?: Record +} + +export function logActivity( + action: string, + path: string, + method: string, + user?: { id: string; email: string; role: string } | null, + details?: Record, + request?: Request +) { + const timestamp = new Date().toISOString() + const ip = request?.headers.get("x-forwarded-for") || + request?.headers.get("x-real-ip") || + "unknown" + + const log: ActivityLog = { + timestamp, + userId: user?.id, + userEmail: user?.email, + userRole: user?.role, + action, + path, + method, + ip: ip.split(",")[0].trim(), // Get first IP if multiple + 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}`) + + return log +} diff --git a/proxy.ts b/proxy.ts index fbb39e6..9595fe8 100644 --- a/proxy.ts +++ b/proxy.ts @@ -19,27 +19,21 @@ export async function proxy(request: NextRequest) { cookieName: cookieName }) - // Debug logging for production troubleshooting - const cookieHeader = request.headers.get("cookie") || "" - const hasCookie = cookieHeader.includes(cookieName) + // User activity logging - track all page visits and API calls + const timestamp = new Date().toISOString() + const userAgent = request.headers.get("user-agent") || "unknown" + const ip = request.headers.get("x-forwarded-for") || + request.headers.get("x-real-ip") || + "unknown" + const referer = request.headers.get("referer") || "direct" + const method = request.method - if (!token) { - console.log("Middleware: No token found", { - pathname, - cookieName, - hasCookie, - cookieHeader: cookieHeader.substring(0, 300), - allCookies: cookieHeader.split(";").map(c => c.trim().substring(0, 50)), - origin: request.headers.get("origin"), - referer: request.headers.get("referer") - }) + if (token) { + // Log authenticated user activity + console.log(`[ACTIVITY] ${timestamp} | ${method} ${pathname} | User: ${token.email} (${token.role}) | IP: ${ip} | Referer: ${referer}`) } else { - console.log("Middleware: Token found", { - pathname, - tokenId: token.id, - tokenRole: token.role, - tokenEmail: token.email - }) + // Log unauthenticated access attempts + console.log(`[ACTIVITY] ${timestamp} | ${method} ${pathname} | User: UNAUTHENTICATED | IP: ${ip} | Referer: ${referer} | UA: ${userAgent.substring(0, 100)}`) } // Protected routes - require authentication diff --git a/watch-activity.sh b/watch-activity.sh new file mode 100644 index 0000000..cb24fb3 --- /dev/null +++ b/watch-activity.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Watch user activity logs in real-time +# Usage: ./watch-activity.sh + +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\]"