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
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:
+68
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user