feat: Add photo management features, duplicate detection, attempt limits, and admin deletion
CI / skip-ci-check (pull_request) Successful in 1m19s
CI / lint-and-type-check (pull_request) Failing after 1m37s
CI / test (pull_request) Successful in 2m16s
CI / build (pull_request) Failing after 1m46s
CI / secret-scanning (pull_request) Successful in 1m20s
CI / dependency-scan (pull_request) Successful in 1m27s
CI / sast-scan (pull_request) Successful in 2m29s
CI / workflow-summary (pull_request) Successful in 1m18s

- Add duplicate photo detection (file hash and URL checking)
- Add max attempts per photo with UI counter
- Simplify penalty system (auto-enable when points > 0)
- Prevent scores from going below 0
- Add admin photo deletion functionality
- Improve navigation with always-visible logout
- Prevent users from guessing their own photos
This commit is contained in:
2026-01-02 14:57:30 -05:00
commit 9640627972
69 changed files with 20858 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { prisma } from "./prisma"
import bcrypt from "bcryptjs"
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null
}
const email = credentials.email as string
const password = credentials.password as string
const user = await prisma.user.findUnique({
where: { email }
})
if (!user || !user.passwordHash) {
return null
}
const isValid = await bcrypt.compare(password, user.passwordHash)
if (!isValid) {
return null
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
}
}
})
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.role = (user as { role: string }).role
}
return token
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string
session.user.role = token.role as string
}
return session
}
},
pages: {
signIn: "/login",
},
session: {
strategy: "jwt",
},
secret: process.env.NEXTAUTH_SECRET,
})
+113
View File
@@ -0,0 +1,113 @@
import nodemailer from "nodemailer"
let transporter: nodemailer.Transporter | null = null
async function getTransporter() {
if (transporter) return transporter
// In development, use Ethereal or console transport
if (process.env.NODE_ENV === "development") {
// Try to use Ethereal for testing
try {
const testAccount = await nodemailer.createTestAccount()
transporter = nodemailer.createTransport({
host: "smtp.ethereal.email",
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
})
return transporter
} catch {
// Fallback to console transport
transporter = nodemailer.createTransport({
streamTransport: true,
newline: "unix",
buffer: true,
})
return transporter
}
}
// Production: use SMTP
transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || "587"),
secure: process.env.SMTP_PORT === "465",
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
})
return transporter
}
export async function sendNewPhotoEmail(
recipientEmail: string,
recipientName: string,
photoId: string,
uploaderName: string
) {
const emailTransporter = await getTransporter()
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000"
const photoUrl = `${baseUrl}/photos/${photoId}`
const mailOptions = {
from: process.env.SMTP_FROM || "MirrorMatch <noreply@mirrormatch.com>",
to: recipientEmail,
subject: "New Photo Ready to Guess!",
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0;">MirrorMatch</h1>
</div>
<div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 10px 10px;">
<h2 style="color: #667eea; margin-top: 0;">New Photo Uploaded!</h2>
<p>Hi ${recipientName},</p>
<p><strong>${uploaderName}</strong> has uploaded a new photo for you to guess!</p>
<p style="margin: 30px 0;">
<a href="${photoUrl}" style="background: #667eea; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">View Photo & Guess</a>
</p>
<p style="color: #666; font-size: 14px; margin-top: 30px;">
Good luck! 🎯
</p>
</div>
</body>
</html>
`,
text: `
Hi ${recipientName},
${uploaderName} has uploaded a new photo for you to guess!
View the photo and submit your guess here: ${photoUrl}
Good luck!
`.trim(),
}
try {
const info = await emailTransporter.sendMail(mailOptions)
if (process.env.NODE_ENV === "development") {
console.log("Email sent (dev mode):")
console.log("Preview URL:", nodemailer.getTestMessageUrl(info) || "Check console")
console.log("To:", recipientEmail)
console.log("Subject:", mailOptions.subject)
}
return info
} catch (error) {
console.error("Error sending email:", error)
throw error
}
}
+46
View File
@@ -0,0 +1,46 @@
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
import { Pool } from 'pg'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
const connectionString = process.env.DATABASE_URL
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is not set')
}
// Handle Prisma Postgres URLs (prisma+postgres://) vs standard PostgreSQL URLs
let pool: Pool
if (connectionString.startsWith('prisma+postgres://')) {
// For Prisma managed Postgres, extract the actual postgres URL from the API key
try {
const urlMatch = connectionString.match(/api_key=([^"&]+)/)
if (urlMatch) {
const apiKey = decodeURIComponent(urlMatch[1])
const decoded = JSON.parse(Buffer.from(apiKey, 'base64').toString())
if (decoded.databaseUrl) {
pool = new Pool({ connectionString: decoded.databaseUrl })
} else if (decoded.shadowDatabaseUrl) {
pool = new Pool({ connectionString: decoded.shadowDatabaseUrl })
} else {
throw new Error('Could not extract database URL from Prisma Postgres connection string')
}
} else {
throw new Error('Invalid Prisma Postgres connection string format')
}
} catch (error) {
console.error('Error parsing Prisma Postgres URL:', error)
throw new Error('Failed to parse Prisma Postgres connection string. Consider using a standard PostgreSQL connection string.')
}
} else {
// Standard PostgreSQL connection
pool = new Pool({ connectionString })
}
const adapter = new PrismaPg(pool)
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter })
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
+9
View File
@@ -0,0 +1,9 @@
import bcrypt from "bcryptjs"
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10)
}
export function normalizeString(str: string): string {
return str.trim().toLowerCase()
}