This PR adds comprehensive photo management features, duplicate detection, attempt limits, penalty system improvements, and admin photo deletion capabilities to the MirrorMatch application. (#1)
CI / skip-ci-check (push) Successful in 1m21s
CI / lint-and-type-check (push) Successful in 1m45s
CI / test (push) Successful in 1m49s
CI / build (push) Successful in 1m50s
CI / secret-scanning (push) Successful in 1m22s
CI / dependency-scan (push) Successful in 1m27s
CI / sast-scan (push) Successful in 2m27s
CI / workflow-summary (push) Successful in 1m19s
CI / skip-ci-check (push) Successful in 1m21s
CI / lint-and-type-check (push) Successful in 1m45s
CI / test (push) Successful in 1m49s
CI / build (push) Successful in 1m50s
CI / secret-scanning (push) Successful in 1m22s
CI / dependency-scan (push) Successful in 1m27s
CI / sast-scan (push) Successful in 2m27s
CI / workflow-summary (push) Successful in 1m19s
# Photo Management and Game Features ## Summary This PR adds comprehensive photo management features, duplicate detection, attempt limits, penalty system improvements, and admin photo deletion capabilities to the MirrorMatch application. ## Features Added ### 1. Duplicate Photo Detection - **File-based duplicates**: Calculates SHA256 hash of uploaded files to detect duplicate content - **URL-based duplicates**: Checks for duplicate photo URLs - Prevents users from uploading the same photo multiple times - Returns HTTP 409 (Conflict) with clear error messages ### 2. Maximum Attempts Per Photo - Uploaders can set a maximum number of guesses allowed per user for each photo - Default: unlimited (null or 0) - UI displays remaining attempts counter - API enforces attempt limits before allowing guesses - Shows warning message when max attempts reached ### 3. Penalty System Improvements - **Simplified UI**: Removed checkbox - penalty automatically enabled when penalty points > 0 - **Score protection**: Scores cannot go below 0, even with large penalties - If penalty would result in negative score, only deducts available points and sets to 0 ### 4. Admin Photo Deletion - Admins can delete photos from: - Photos list page (hover to reveal delete icon) - Individual photo detail page (delete button in header) - Deletes associated guesses automatically - Deletes local uploaded files from filesystem - Confirmation dialog before deletion - Proper error handling and user feedback ### 5. Navigation Improvements - Logout button always visible in side menu (hamburger menu) - Improved side menu layout with fixed footer for logout button - Better mobile responsiveness ### 6. Self-Guess Prevention - Users cannot guess on their own uploaded photos - Shows informative message with answer for photo owners ## Technical Changes ### Database Schema - Added `fileHash` field (String?) to Photo model for duplicate detection - Added `maxAttempts` field (Int?) to Photo model for attempt limits - Added indexes on `url` and `fileHash` for performance ### API Routes - `POST /api/photos/upload-multiple`: Enhanced with duplicate checking and maxAttempts - `POST /api/photos/[photoId]/guess`: Added maxAttempts enforcement and score floor protection - `DELETE /api/photos/[photoId]`: New route for admin photo deletion ### Components - `DeletePhotoButton`: New reusable component for photo deletion - Updated upload form to remove penalty checkbox - Enhanced photo display pages with attempt counters and admin controls ## Database Migrations - Run `npm run db:push` to apply schema changes - Run `npm run db:generate` to regenerate Prisma client ## Testing - Test duplicate detection with same file and different filenames - Test duplicate detection with same URL - Test max attempts enforcement - Test penalty system with various point values - Test score floor (cannot go below 0) - Test admin photo deletion - Test self-guess prevention ## Breaking Changes None - all changes are backward compatible. Existing photos will have `null` for `maxAttempts` (unlimited) and `fileHash` (for URL uploads). Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -47,7 +47,7 @@ export async function DELETE(
|
||||
try {
|
||||
await unlink(filepath)
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete file ${filepath}:`, error)
|
||||
console.error("Failed to delete file:", filepath, error)
|
||||
// Continue with database deletion even if file deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export async function POST(req: NextRequest) {
|
||||
answerName: answerName.trim(),
|
||||
points: pointsValue,
|
||||
maxAttempts: maxAttemptsValue,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
include: {
|
||||
uploader: {
|
||||
@@ -72,7 +73,9 @@ export async function POST(req: NextRequest) {
|
||||
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)
|
||||
(err) => {
|
||||
console.error("Failed to send email to:", user.email, err)
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ import { writeFile } from "fs/promises"
|
||||
import { join } from "path"
|
||||
import { existsSync, mkdirSync } from "fs"
|
||||
import { createHash } from "crypto"
|
||||
import type { Photo } from "@prisma/client"
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -44,7 +43,13 @@ export async function POST(req: NextRequest) {
|
||||
mkdirSync(uploadsDir, { recursive: true })
|
||||
}
|
||||
|
||||
type PhotoWithUploader = Photo & {
|
||||
type PhotoWithUploader = {
|
||||
id: string
|
||||
uploaderId: string
|
||||
url: string
|
||||
answerName: string
|
||||
points: number
|
||||
createdAt: Date
|
||||
uploader: { name: string }
|
||||
}
|
||||
|
||||
@@ -106,6 +111,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// Check for duplicate file
|
||||
const existingPhoto = await prisma.photo.findFirst({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
where: { fileHash } as any,
|
||||
})
|
||||
|
||||
@@ -118,9 +124,12 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const timestamp = Date.now()
|
||||
const randomStr = Math.random().toString(36).substring(2, 15)
|
||||
const extension = file.name.split(".").pop() || "jpg"
|
||||
// Sanitize extension - only allow alphanumeric characters
|
||||
const rawExtension = file.name.split(".").pop() || "jpg"
|
||||
const extension = rawExtension.replace(/[^a-zA-Z0-9]/g, "").toLowerCase() || "jpg"
|
||||
const filename = `${timestamp}-${i}-${randomStr}.${extension}`
|
||||
|
||||
// Filename is generated server-side (timestamp + random), safe for path.join
|
||||
const filepath = join(uploadsDir, filename)
|
||||
await writeFile(filepath, buffer)
|
||||
|
||||
@@ -158,6 +167,7 @@ export async function POST(req: NextRequest) {
|
||||
penaltyEnabled: penaltyEnabled,
|
||||
penaltyPoints: penaltyPointsValue,
|
||||
maxAttempts: maxAttemptsValue,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
include: {
|
||||
uploader: {
|
||||
@@ -168,7 +178,7 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
})
|
||||
|
||||
createdPhotos.push(photo as PhotoWithUploader)
|
||||
createdPhotos.push(photo)
|
||||
}
|
||||
|
||||
// Send emails to all other users for all photos
|
||||
@@ -193,12 +203,9 @@ export async function POST(req: NextRequest) {
|
||||
user.name,
|
||||
photo.id,
|
||||
photo.uploader.name
|
||||
).catch((err) =>
|
||||
console.error(
|
||||
`Failed to send email to ${user.email} for photo ${photo.id}:`,
|
||||
err
|
||||
)
|
||||
)
|
||||
).catch((err) => {
|
||||
console.error("Failed to send email to:", user.email, "for photo:", photo.id, err)
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -62,6 +62,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// Check for duplicate file
|
||||
const existingPhoto = await prisma.photo.findFirst({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
where: { fileHash } as any,
|
||||
})
|
||||
|
||||
@@ -75,7 +76,9 @@ export async function POST(req: NextRequest) {
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now()
|
||||
const randomStr = Math.random().toString(36).substring(2, 15)
|
||||
const extension = file.name.split(".").pop() || "jpg"
|
||||
// Sanitize extension - only allow alphanumeric characters
|
||||
const rawExtension = file.name.split(".").pop() || "jpg"
|
||||
const extension = rawExtension.replace(/[^a-zA-Z0-9]/g, "").toLowerCase() || "jpg"
|
||||
const filename = `${timestamp}-${randomStr}.${extension}`
|
||||
|
||||
// Ensure uploads directory exists
|
||||
@@ -84,7 +87,7 @@ export async function POST(req: NextRequest) {
|
||||
mkdirSync(uploadsDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Save file
|
||||
// Filename is generated server-side (timestamp + random), safe for path.join
|
||||
const filepath = join(uploadsDir, filename)
|
||||
await writeFile(filepath, buffer)
|
||||
|
||||
@@ -122,6 +125,7 @@ export async function POST(req: NextRequest) {
|
||||
answerName: answerName.trim(),
|
||||
points: pointsValue,
|
||||
maxAttempts: maxAttemptsValue,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
include: {
|
||||
uploader: {
|
||||
@@ -148,7 +152,9 @@ export async function POST(req: NextRequest) {
|
||||
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)
|
||||
(err) => {
|
||||
console.error("Failed to send email to:", user.email, err)
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user