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.
This commit is contained in:
ilia 2026-01-04 14:29:17 -05:00
parent 7ced408041
commit 91adbab487
7 changed files with 180 additions and 22 deletions

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth" import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { normalizeString } from "@/lib/utils" import { normalizeString } from "@/lib/utils"
import { logActivity } from "@/lib/activity-log"
export async function POST( export async function POST(
req: NextRequest, 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({ return NextResponse.json({
guess, guess,
correct: isCorrect, correct: isCorrect,

View File

@ -133,7 +133,7 @@ export async function POST(req: NextRequest) {
const filepath = join(uploadsDir, filename) const filepath = join(uploadsDir, filename)
await writeFile(filepath, buffer) await writeFile(filepath, buffer)
photoUrl = `/uploads/${filename}` photoUrl = `/api/uploads/${filename}`
} else if (url) { } else if (url) {
// Handle URL upload // Handle URL upload
photoUrl = url photoUrl = url

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth" import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { sendNewPhotoEmail } from "@/lib/email" import { sendNewPhotoEmail } from "@/lib/email"
import { logActivity } from "@/lib/activity-log"
import { writeFile } from "fs/promises" import { writeFile } from "fs/promises"
import { join } from "path" import { join } from "path"
import { existsSync, mkdirSync } from "fs" import { existsSync, mkdirSync } from "fs"
@ -85,14 +86,26 @@ export async function POST(req: NextRequest) {
const uploadsDir = join(process.cwd(), "public", "uploads") const uploadsDir = join(process.cwd(), "public", "uploads")
if (!existsSync(uploadsDir)) { if (!existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true }) 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 // Filename is generated server-side (timestamp + random), safe for path.join
const filepath = join(uploadsDir, filename) const filepath = join(uploadsDir, filename)
await writeFile(filepath, buffer) await writeFile(filepath, buffer)
// Set URL to the uploaded file // Verify file was written successfully
photoUrl = `/uploads/${filename}` 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 - use API route to ensure it's accessible
photoUrl = `/api/uploads/${filename}`
} else { } else {
// Handle URL upload (fallback) // Handle URL upload (fallback)
const url = formData.get("url") as string | null 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 }) return NextResponse.json({ photo }, { status: 201 })
} catch (error) { } catch (error) {
console.error("Error uploading photo:", error) console.error("Error uploading photo:", error)

View File

@ -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 }
)
}
}

52
lib/activity-log.ts Normal file
View File

@ -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<string, any>
}
export function logActivity(
action: string,
path: string,
method: string,
user?: { id: string; email: string; role: string } | null,
details?: Record<string, any>,
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
}

View File

@ -19,27 +19,21 @@ export async function proxy(request: NextRequest) {
cookieName: cookieName cookieName: cookieName
}) })
// Debug logging for production troubleshooting // User activity logging - track all page visits and API calls
const cookieHeader = request.headers.get("cookie") || "" const timestamp = new Date().toISOString()
const hasCookie = cookieHeader.includes(cookieName) 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) { if (token) {
console.log("Middleware: No token found", { // Log authenticated user activity
pathname, console.log(`[ACTIVITY] ${timestamp} | ${method} ${pathname} | User: ${token.email} (${token.role}) | IP: ${ip} | Referer: ${referer}`)
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")
})
} else { } else {
console.log("Middleware: Token found", { // Log unauthenticated access attempts
pathname, console.log(`[ACTIVITY] ${timestamp} | ${method} ${pathname} | User: UNAUTHENTICATED | IP: ${ip} | Referer: ${referer} | UA: ${userAgent.substring(0, 100)}`)
tokenId: token.id,
tokenRole: token.role,
tokenEmail: token.email
})
} }
// Protected routes - require authentication // Protected routes - require authentication

10
watch-activity.sh Normal file
View File

@ -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\]"