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:
parent
7ced408041
commit
91adbab487
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
// Set URL to the uploaded file
|
||||
photoUrl = `/uploads/${filename}`
|
||||
// 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 - 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)
|
||||
|
||||
58
app/api/uploads/[filename]/route.ts
Normal file
58
app/api/uploads/[filename]/route.ts
Normal 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
52
lib/activity-log.ts
Normal 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
|
||||
}
|
||||
32
proxy.ts
32
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
|
||||
|
||||
10
watch-activity.sh
Normal file
10
watch-activity.sh
Normal 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\]"
|
||||
Loading…
x
Reference in New Issue
Block a user