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 { 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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
// 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
|
// Set URL to the uploaded file - use API route to ensure it's accessible
|
||||||
photoUrl = `/uploads/${filename}`
|
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)
|
||||||
|
|||||||
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
|
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
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