Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
This commit is contained in:
@@ -1,48 +1,48 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Read retention period from system settings (default: 365 days)
|
||||
const retentionSetting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'audit_retention_days' },
|
||||
})
|
||||
|
||||
const retentionDays = retentionSetting
|
||||
? parseInt(retentionSetting.value, 10) || 365
|
||||
: 365
|
||||
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays)
|
||||
|
||||
// Delete audit log entries older than the retention period
|
||||
const result = await prisma.auditLog.deleteMany({
|
||||
where: {
|
||||
timestamp: {
|
||||
lt: cutoffDate,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
cleanedUp: result.count,
|
||||
retentionDays,
|
||||
cutoffDate: cutoffDate.toISOString(),
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Cron audit cleanup failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Read retention period from system settings (default: 365 days)
|
||||
const retentionSetting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'audit_retention_days' },
|
||||
})
|
||||
|
||||
const retentionDays = retentionSetting
|
||||
? parseInt(retentionSetting.value, 10) || 365
|
||||
: 365
|
||||
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays)
|
||||
|
||||
// Delete audit log entries older than the retention period
|
||||
const result = await prisma.auditLog.deleteMany({
|
||||
where: {
|
||||
timestamp: {
|
||||
lt: cutoffDate,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
cleanedUp: result.count,
|
||||
retentionDays,
|
||||
cutoffDate: cutoffDate.toISOString(),
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Cron audit cleanup failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { processDigests } from '@/server/services/email-digest'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine digest type: check query param, or default based on day of week
|
||||
const { searchParams } = new URL(request.url)
|
||||
let digestType = searchParams.get('type') as 'daily' | 'weekly' | null
|
||||
|
||||
if (!digestType) {
|
||||
const dayOfWeek = new Date().getDay()
|
||||
// Monday = 1 → run weekly; all other days → run daily
|
||||
digestType = dayOfWeek === 1 ? 'weekly' : 'daily'
|
||||
}
|
||||
|
||||
const result = await processDigests(digestType)
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
digestType,
|
||||
sent: result.sent,
|
||||
errors: result.errors,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Cron digest processing failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { processDigests } from '@/server/services/email-digest'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine digest type: check query param, or default based on day of week
|
||||
const { searchParams } = new URL(request.url)
|
||||
let digestType = searchParams.get('type') as 'daily' | 'weekly' | null
|
||||
|
||||
if (!digestType) {
|
||||
const dayOfWeek = new Date().getDay()
|
||||
// Monday = 1 → run weekly; all other days → run daily
|
||||
digestType = dayOfWeek === 1 ? 'weekly' : 'daily'
|
||||
}
|
||||
|
||||
const result = await processDigests(digestType)
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
digestType,
|
||||
sent: result.sent,
|
||||
errors: result.errors,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Cron digest processing failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date()
|
||||
|
||||
// Delete projects where isDraft=true AND draftExpiresAt has passed
|
||||
const result = await prisma.project.deleteMany({
|
||||
where: {
|
||||
isDraft: true,
|
||||
draftExpiresAt: {
|
||||
lt: now,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
cleanedUp: result.count,
|
||||
timestamp: now.toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Cron draft cleanup failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date()
|
||||
|
||||
// Delete projects where isDraft=true AND draftExpiresAt has passed
|
||||
const result = await prisma.project.deleteMany({
|
||||
where: {
|
||||
isDraft: true,
|
||||
draftExpiresAt: {
|
||||
lt: now,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
cleanedUp: result.count,
|
||||
timestamp: now.toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Cron draft cleanup failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +1,145 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
|
||||
const SMTP_HOST = process.env.SMTP_HOST || 'localhost'
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587')
|
||||
const POSTE_API_URL = process.env.POSTE_API_URL || 'https://mail.monaco-opc.com'
|
||||
const POSTE_ADMIN_EMAIL = process.env.POSTE_ADMIN_EMAIL || ''
|
||||
const POSTE_ADMIN_PASSWORD = process.env.POSTE_ADMIN_PASSWORD || ''
|
||||
|
||||
const PASSWORD_MIN_LENGTH = 8
|
||||
const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/
|
||||
|
||||
function validateNewPassword(password: string): string | null {
|
||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||
return `Password must be at least ${PASSWORD_MIN_LENGTH} characters.`
|
||||
}
|
||||
if (!PASSWORD_REGEX.test(password)) {
|
||||
return 'Password must contain at least one uppercase letter, one lowercase letter, and one number.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
// Verify authenticated session
|
||||
const session = await auth()
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
||||
const rateLimit = checkRateLimit(`email-change:${ip}`, 3, 15 * 60 * 1000)
|
||||
|
||||
if (!rateLimit.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many attempts. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { email, currentPassword, newPassword } = body as {
|
||||
email: string
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
if (!email || !currentPassword || !newPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'All fields are required.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const emailLower = email.toLowerCase().trim()
|
||||
|
||||
// Verify the user can only change their own email password
|
||||
if (emailLower !== session.user.email.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You can only change your own email password.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Email must be an @${MAIL_DOMAIN} address.` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const passwordError = validateNewPassword(newPassword)
|
||||
if (passwordError) {
|
||||
return NextResponse.json({ error: passwordError }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!POSTE_ADMIN_EMAIL || !POSTE_ADMIN_PASSWORD) {
|
||||
console.error('Poste.io admin credentials not configured')
|
||||
return NextResponse.json(
|
||||
{ error: 'Email service is not configured. Contact an administrator.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
// Re-verify current credentials via SMTP
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_PORT === 465,
|
||||
auth: {
|
||||
user: emailLower,
|
||||
pass: currentPassword,
|
||||
},
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
})
|
||||
|
||||
try {
|
||||
await transporter.verify()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Current password is incorrect.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
} finally {
|
||||
transporter.close()
|
||||
}
|
||||
|
||||
// Change password via Poste.io Admin API
|
||||
const apiUrl = `${POSTE_API_URL}/admin/api/v1/boxes/${encodeURIComponent(emailLower)}`
|
||||
const authHeader = 'Basic ' + Buffer.from(`${POSTE_ADMIN_EMAIL}:${POSTE_ADMIN_PASSWORD}`).toString('base64')
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': authHeader,
|
||||
},
|
||||
body: JSON.stringify({ passwordPlaintext: newPassword }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Poste.io API error:', response.status, await response.text())
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to change password. Please try again or contact an administrator.' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (err) {
|
||||
console.error('Password change error:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'An unexpected error occurred.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
|
||||
const SMTP_HOST = process.env.SMTP_HOST || 'localhost'
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587')
|
||||
const POSTE_API_URL = process.env.POSTE_API_URL || 'https://mail.monaco-opc.com'
|
||||
const POSTE_ADMIN_EMAIL = process.env.POSTE_ADMIN_EMAIL || ''
|
||||
const POSTE_ADMIN_PASSWORD = process.env.POSTE_ADMIN_PASSWORD || ''
|
||||
|
||||
const PASSWORD_MIN_LENGTH = 8
|
||||
const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/
|
||||
|
||||
function validateNewPassword(password: string): string | null {
|
||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||
return `Password must be at least ${PASSWORD_MIN_LENGTH} characters.`
|
||||
}
|
||||
if (!PASSWORD_REGEX.test(password)) {
|
||||
return 'Password must contain at least one uppercase letter, one lowercase letter, and one number.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
// Verify authenticated session
|
||||
const session = await auth()
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
||||
const rateLimit = checkRateLimit(`email-change:${ip}`, 3, 15 * 60 * 1000)
|
||||
|
||||
if (!rateLimit.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many attempts. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { email, currentPassword, newPassword } = body as {
|
||||
email: string
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
if (!email || !currentPassword || !newPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'All fields are required.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const emailLower = email.toLowerCase().trim()
|
||||
|
||||
// Verify the user can only change their own email password
|
||||
if (emailLower !== session.user.email.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You can only change your own email password.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Email must be an @${MAIL_DOMAIN} address.` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const passwordError = validateNewPassword(newPassword)
|
||||
if (passwordError) {
|
||||
return NextResponse.json({ error: passwordError }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!POSTE_ADMIN_EMAIL || !POSTE_ADMIN_PASSWORD) {
|
||||
console.error('Poste.io admin credentials not configured')
|
||||
return NextResponse.json(
|
||||
{ error: 'Email service is not configured. Contact an administrator.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
// Re-verify current credentials via SMTP
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_PORT === 465,
|
||||
auth: {
|
||||
user: emailLower,
|
||||
pass: currentPassword,
|
||||
},
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
})
|
||||
|
||||
try {
|
||||
await transporter.verify()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Current password is incorrect.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
} finally {
|
||||
transporter.close()
|
||||
}
|
||||
|
||||
// Change password via Poste.io Admin API
|
||||
const apiUrl = `${POSTE_API_URL}/admin/api/v1/boxes/${encodeURIComponent(emailLower)}`
|
||||
const authHeader = 'Basic ' + Buffer.from(`${POSTE_ADMIN_EMAIL}:${POSTE_ADMIN_PASSWORD}`).toString('base64')
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': authHeader,
|
||||
},
|
||||
body: JSON.stringify({ passwordPlaintext: newPassword }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Poste.io API error:', response.status, await response.text())
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to change password. Please try again or contact an administrator.' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (err) {
|
||||
console.error('Password change error:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'An unexpected error occurred.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
|
||||
const SMTP_HOST = process.env.SMTP_HOST || 'localhost'
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587')
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
// Verify authenticated session
|
||||
const session = await auth()
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
||||
const rateLimit = checkRateLimit(`email-verify:${ip}`, 5, 15 * 60 * 1000)
|
||||
|
||||
if (!rateLimit.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many attempts. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { email, password } = body as { email: string; password: string }
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email and password are required.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const emailLower = email.toLowerCase().trim()
|
||||
|
||||
// Verify the user can only check their own email credentials
|
||||
if (emailLower !== session.user.email.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You can only verify your own email credentials.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Email must be an @${MAIL_DOMAIN} address.` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_PORT === 465,
|
||||
auth: {
|
||||
user: emailLower,
|
||||
pass: password,
|
||||
},
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
})
|
||||
|
||||
try {
|
||||
await transporter.verify()
|
||||
return NextResponse.json({ valid: true })
|
||||
} catch {
|
||||
return NextResponse.json({ valid: false, error: 'Invalid email or password.' })
|
||||
} finally {
|
||||
transporter.close()
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
|
||||
const SMTP_HOST = process.env.SMTP_HOST || 'localhost'
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587')
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
// Verify authenticated session
|
||||
const session = await auth()
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
||||
const rateLimit = checkRateLimit(`email-verify:${ip}`, 5, 15 * 60 * 1000)
|
||||
|
||||
if (!rateLimit.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many attempts. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { email, password } = body as { email: string; password: string }
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email and password are required.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const emailLower = email.toLowerCase().trim()
|
||||
|
||||
// Verify the user can only check their own email credentials
|
||||
if (emailLower !== session.user.email.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You can only verify your own email credentials.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Email must be an @${MAIL_DOMAIN} address.` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_PORT === 465,
|
||||
auth: {
|
||||
user: emailLower,
|
||||
pass: password,
|
||||
},
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
})
|
||||
|
||||
try {
|
||||
await transporter.verify()
|
||||
return NextResponse.json({ valid: true })
|
||||
} catch {
|
||||
return NextResponse.json({ valid: false, error: 'Invalid email or password.' })
|
||||
} finally {
|
||||
transporter.close()
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +1,124 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getPresignedUrl, BUCKET_NAME } from '@/lib/minio'
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { projectId, fileIds } = body as {
|
||||
projectId?: string
|
||||
fileIds?: string[]
|
||||
}
|
||||
|
||||
if (!projectId || !fileIds || !Array.isArray(fileIds) || fileIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'projectId and fileIds array are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const userRole = session.user.role
|
||||
|
||||
// Authorization: must be admin or assigned jury/mentor for this project
|
||||
const isAdmin = userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN'
|
||||
|
||||
if (!isAdmin) {
|
||||
// Check if user is assigned as jury
|
||||
const juryAssignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
})
|
||||
|
||||
// Check if user is assigned as mentor
|
||||
const mentorAssignment = await prisma.mentorAssignment.findFirst({
|
||||
where: {
|
||||
mentorId: userId,
|
||||
projectId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!juryAssignment && !mentorAssignment) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You do not have access to this project\'s files' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch file metadata from DB
|
||||
const files = await prisma.projectFile.findMany({
|
||||
where: {
|
||||
id: { in: fileIds },
|
||||
projectId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
objectKey: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (files.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No matching files found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate signed download URLs for each file
|
||||
const downloadUrls = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
try {
|
||||
const downloadUrl = await getPresignedUrl(
|
||||
BUCKET_NAME,
|
||||
file.objectKey,
|
||||
'GET',
|
||||
3600 // 1 hour expiry for bulk downloads
|
||||
)
|
||||
|
||||
return {
|
||||
id: file.id,
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size,
|
||||
downloadUrl,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[BulkDownload] Failed to get URL for file ${file.id}:`, error)
|
||||
return {
|
||||
id: file.id,
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size,
|
||||
downloadUrl: null,
|
||||
error: 'Failed to generate download URL',
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
projectId,
|
||||
files: downloadUrls,
|
||||
expiresIn: 3600,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[BulkDownload] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getPresignedUrl, BUCKET_NAME } from '@/lib/minio'
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { projectId, fileIds } = body as {
|
||||
projectId?: string
|
||||
fileIds?: string[]
|
||||
}
|
||||
|
||||
if (!projectId || !fileIds || !Array.isArray(fileIds) || fileIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'projectId and fileIds array are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const userRole = session.user.role
|
||||
|
||||
// Authorization: must be admin or assigned jury/mentor for this project
|
||||
const isAdmin = userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN'
|
||||
|
||||
if (!isAdmin) {
|
||||
// Check if user is assigned as jury
|
||||
const juryAssignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
})
|
||||
|
||||
// Check if user is assigned as mentor
|
||||
const mentorAssignment = await prisma.mentorAssignment.findFirst({
|
||||
where: {
|
||||
mentorId: userId,
|
||||
projectId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!juryAssignment && !mentorAssignment) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You do not have access to this project\'s files' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch file metadata from DB
|
||||
const files = await prisma.projectFile.findMany({
|
||||
where: {
|
||||
id: { in: fileIds },
|
||||
projectId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
objectKey: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (files.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No matching files found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate signed download URLs for each file
|
||||
const downloadUrls = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
try {
|
||||
const downloadUrl = await getPresignedUrl(
|
||||
BUCKET_NAME,
|
||||
file.objectKey,
|
||||
'GET',
|
||||
3600 // 1 hour expiry for bulk downloads
|
||||
)
|
||||
|
||||
return {
|
||||
id: file.id,
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size,
|
||||
downloadUrl,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[BulkDownload] Failed to get URL for file ${file.id}:`, error)
|
||||
return {
|
||||
id: file.id,
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size,
|
||||
downloadUrl: null,
|
||||
error: 'Failed to generate download URL',
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
projectId,
|
||||
files: downloadUrls,
|
||||
expiresIn: 3600,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[BulkDownload] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,215 +1,215 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<Response> {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const sessionId = searchParams.get('sessionId')
|
||||
|
||||
if (!sessionId) {
|
||||
return new Response(JSON.stringify({ error: 'sessionId is required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Verify the session exists
|
||||
const session = await prisma.liveVotingSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: 'Session not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Track state for change detection
|
||||
let lastVoteCount = -1
|
||||
let lastAudienceVoteCount = -1
|
||||
let lastProjectId: string | null = null
|
||||
let lastStatus: string | null = null
|
||||
|
||||
const sendEvent = (event: string, data: unknown) => {
|
||||
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
||||
controller.enqueue(encoder.encode(payload))
|
||||
}
|
||||
|
||||
// Send initial connection event
|
||||
sendEvent('connected', { sessionId, timestamp: new Date().toISOString() })
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const currentSession = await prisma.liveVotingSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: {
|
||||
status: true,
|
||||
currentProjectId: true,
|
||||
currentProjectIndex: true,
|
||||
votingEndsAt: true,
|
||||
allowAudienceVotes: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!currentSession) {
|
||||
sendEvent('session_status', { status: 'DELETED' })
|
||||
controller.close()
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for status changes
|
||||
if (lastStatus !== null && currentSession.status !== lastStatus) {
|
||||
sendEvent('session_status', {
|
||||
status: currentSession.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastStatus = currentSession.status
|
||||
|
||||
// Check for project changes
|
||||
if (
|
||||
lastProjectId !== null &&
|
||||
currentSession.currentProjectId !== lastProjectId
|
||||
) {
|
||||
sendEvent('project_change', {
|
||||
projectId: currentSession.currentProjectId,
|
||||
projectIndex: currentSession.currentProjectIndex,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastProjectId = currentSession.currentProjectId
|
||||
|
||||
// Check for vote updates on the current project
|
||||
if (currentSession.currentProjectId) {
|
||||
// Jury votes
|
||||
const juryVoteCount = await prisma.liveVote.count({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (lastVoteCount !== -1 && juryVoteCount !== lastVoteCount) {
|
||||
const latestVotes = await prisma.liveVote.findMany({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
select: {
|
||||
score: true,
|
||||
isAudienceVote: true,
|
||||
votedAt: true,
|
||||
},
|
||||
orderBy: { votedAt: 'desc' },
|
||||
take: 1,
|
||||
})
|
||||
|
||||
const avgScore = await prisma.liveVote.aggregate({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
_avg: { score: true },
|
||||
_count: true,
|
||||
})
|
||||
|
||||
sendEvent('vote_update', {
|
||||
projectId: currentSession.currentProjectId,
|
||||
totalVotes: juryVoteCount,
|
||||
averageScore: avgScore._avg.score,
|
||||
latestVote: latestVotes[0] || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastVoteCount = juryVoteCount
|
||||
|
||||
// Audience votes (separate event)
|
||||
if (currentSession.allowAudienceVotes) {
|
||||
const audienceVoteCount = await prisma.liveVote.count({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (lastAudienceVoteCount !== -1 && audienceVoteCount !== lastAudienceVoteCount) {
|
||||
const audienceAvg = await prisma.liveVote.aggregate({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: true,
|
||||
},
|
||||
_avg: { score: true },
|
||||
})
|
||||
|
||||
sendEvent('audience_vote', {
|
||||
projectId: currentSession.currentProjectId,
|
||||
audienceVotes: audienceVoteCount,
|
||||
audienceAverage: audienceAvg._avg.score,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastAudienceVoteCount = audienceVoteCount
|
||||
}
|
||||
}
|
||||
|
||||
// Stop polling if session is completed
|
||||
if (currentSession.status === 'COMPLETED') {
|
||||
sendEvent('session_status', {
|
||||
status: 'COMPLETED',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
controller.close()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[SSE] Poll error:', error)
|
||||
return true // Keep trying
|
||||
}
|
||||
}
|
||||
|
||||
// Initial poll to set baseline state
|
||||
const shouldContinue = await poll()
|
||||
if (!shouldContinue) return
|
||||
|
||||
// Poll every 2 seconds
|
||||
const interval = setInterval(async () => {
|
||||
const cont = await poll()
|
||||
if (!cont) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
// Clean up on abort
|
||||
request.signal.addEventListener('abort', () => {
|
||||
clearInterval(interval)
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// Stream may already be closed
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<Response> {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const sessionId = searchParams.get('sessionId')
|
||||
|
||||
if (!sessionId) {
|
||||
return new Response(JSON.stringify({ error: 'sessionId is required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Verify the session exists
|
||||
const session = await prisma.liveVotingSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: 'Session not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Track state for change detection
|
||||
let lastVoteCount = -1
|
||||
let lastAudienceVoteCount = -1
|
||||
let lastProjectId: string | null = null
|
||||
let lastStatus: string | null = null
|
||||
|
||||
const sendEvent = (event: string, data: unknown) => {
|
||||
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
||||
controller.enqueue(encoder.encode(payload))
|
||||
}
|
||||
|
||||
// Send initial connection event
|
||||
sendEvent('connected', { sessionId, timestamp: new Date().toISOString() })
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const currentSession = await prisma.liveVotingSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: {
|
||||
status: true,
|
||||
currentProjectId: true,
|
||||
currentProjectIndex: true,
|
||||
votingEndsAt: true,
|
||||
allowAudienceVotes: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!currentSession) {
|
||||
sendEvent('session_status', { status: 'DELETED' })
|
||||
controller.close()
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for status changes
|
||||
if (lastStatus !== null && currentSession.status !== lastStatus) {
|
||||
sendEvent('session_status', {
|
||||
status: currentSession.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastStatus = currentSession.status
|
||||
|
||||
// Check for project changes
|
||||
if (
|
||||
lastProjectId !== null &&
|
||||
currentSession.currentProjectId !== lastProjectId
|
||||
) {
|
||||
sendEvent('project_change', {
|
||||
projectId: currentSession.currentProjectId,
|
||||
projectIndex: currentSession.currentProjectIndex,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastProjectId = currentSession.currentProjectId
|
||||
|
||||
// Check for vote updates on the current project
|
||||
if (currentSession.currentProjectId) {
|
||||
// Jury votes
|
||||
const juryVoteCount = await prisma.liveVote.count({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (lastVoteCount !== -1 && juryVoteCount !== lastVoteCount) {
|
||||
const latestVotes = await prisma.liveVote.findMany({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
select: {
|
||||
score: true,
|
||||
isAudienceVote: true,
|
||||
votedAt: true,
|
||||
},
|
||||
orderBy: { votedAt: 'desc' },
|
||||
take: 1,
|
||||
})
|
||||
|
||||
const avgScore = await prisma.liveVote.aggregate({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
_avg: { score: true },
|
||||
_count: true,
|
||||
})
|
||||
|
||||
sendEvent('vote_update', {
|
||||
projectId: currentSession.currentProjectId,
|
||||
totalVotes: juryVoteCount,
|
||||
averageScore: avgScore._avg.score,
|
||||
latestVote: latestVotes[0] || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastVoteCount = juryVoteCount
|
||||
|
||||
// Audience votes (separate event)
|
||||
if (currentSession.allowAudienceVotes) {
|
||||
const audienceVoteCount = await prisma.liveVote.count({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (lastAudienceVoteCount !== -1 && audienceVoteCount !== lastAudienceVoteCount) {
|
||||
const audienceAvg = await prisma.liveVote.aggregate({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: true,
|
||||
},
|
||||
_avg: { score: true },
|
||||
})
|
||||
|
||||
sendEvent('audience_vote', {
|
||||
projectId: currentSession.currentProjectId,
|
||||
audienceVotes: audienceVoteCount,
|
||||
audienceAverage: audienceAvg._avg.score,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastAudienceVoteCount = audienceVoteCount
|
||||
}
|
||||
}
|
||||
|
||||
// Stop polling if session is completed
|
||||
if (currentSession.status === 'COMPLETED') {
|
||||
sendEvent('session_status', {
|
||||
status: 'COMPLETED',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
controller.close()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[SSE] Poll error:', error)
|
||||
return true // Keep trying
|
||||
}
|
||||
}
|
||||
|
||||
// Initial poll to set baseline state
|
||||
const shouldContinue = await poll()
|
||||
if (!shouldContinue) return
|
||||
|
||||
// Poll every 2 seconds
|
||||
const interval = setInterval(async () => {
|
||||
const cont = await poll()
|
||||
if (!cont) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
// Clean up on abort
|
||||
request.signal.addEventListener('abort', () => {
|
||||
clearInterval(interval)
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// Stream may already be closed
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,216 +1,216 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const POLL_INTERVAL_MS = 2000
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const { sessionId } = await params
|
||||
|
||||
// Validate session exists
|
||||
const cursor = await prisma.liveProgressCursor.findUnique({
|
||||
where: { sessionId },
|
||||
})
|
||||
|
||||
if (!cursor) {
|
||||
return new Response('Session not found', { status: 404 })
|
||||
}
|
||||
|
||||
// Manually fetch related data since LiveProgressCursor doesn't have these relations
|
||||
let activeProject = null
|
||||
if (cursor.activeProjectId) {
|
||||
activeProject = await prisma.project.findUnique({
|
||||
where: { id: cursor.activeProjectId },
|
||||
select: { id: true, title: true, teamName: true, description: true },
|
||||
})
|
||||
}
|
||||
|
||||
const stageInfo = await prisma.stage.findUnique({
|
||||
where: { id: cursor.stageId },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Send initial state
|
||||
type CohortWithProjects = Awaited<ReturnType<typeof prisma.cohort.findMany<{
|
||||
where: { stageId: string }
|
||||
include: { projects: { select: { projectId: true } } }
|
||||
}>>>
|
||||
|
||||
const cohortPromise: Promise<CohortWithProjects> = prisma.cohort
|
||||
.findMany({
|
||||
where: { stageId: cursor.stageId },
|
||||
include: {
|
||||
projects: {
|
||||
select: { projectId: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((cohorts) => {
|
||||
const initData = {
|
||||
activeProject,
|
||||
isPaused: cursor.isPaused,
|
||||
stageInfo,
|
||||
openCohorts: cohorts.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isOpen: c.isOpen,
|
||||
projectIds: c.projects.map((p) => p.projectId),
|
||||
})),
|
||||
}
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: init\ndata: ${JSON.stringify(initData)}\n\n`)
|
||||
)
|
||||
|
||||
return cohorts
|
||||
})
|
||||
.catch((): CohortWithProjects => {
|
||||
// Ignore errors on init
|
||||
return []
|
||||
})
|
||||
|
||||
cohortPromise.then((initialCohorts: CohortWithProjects) => {
|
||||
// Poll for updates
|
||||
let lastActiveProjectId = cursor.activeProjectId
|
||||
let lastIsPaused = cursor.isPaused
|
||||
let lastCohortState = JSON.stringify(
|
||||
(initialCohorts ?? []).map((c: { id: string; isOpen: boolean; windowOpenAt: Date | null; windowCloseAt: Date | null }) => ({
|
||||
id: c.id,
|
||||
isOpen: c.isOpen,
|
||||
windowOpenAt: c.windowOpenAt?.toISOString() ?? null,
|
||||
windowCloseAt: c.windowCloseAt?.toISOString() ?? null,
|
||||
}))
|
||||
)
|
||||
|
||||
intervalId = setInterval(async () => {
|
||||
try {
|
||||
const updated = await prisma.liveProgressCursor.findUnique({
|
||||
where: { sessionId },
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`event: session.ended\ndata: ${JSON.stringify({ reason: 'Session removed' })}\n\n`
|
||||
)
|
||||
)
|
||||
controller.close()
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for cursor changes
|
||||
if (
|
||||
updated.activeProjectId !== lastActiveProjectId ||
|
||||
updated.isPaused !== lastIsPaused
|
||||
) {
|
||||
// Fetch updated active project if changed
|
||||
let updatedActiveProject = null
|
||||
if (updated.activeProjectId) {
|
||||
updatedActiveProject = await prisma.project.findUnique({
|
||||
where: { id: updated.activeProjectId },
|
||||
select: { id: true, title: true, teamName: true, description: true },
|
||||
})
|
||||
}
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`event: cursor.updated\ndata: ${JSON.stringify({
|
||||
activeProject: updatedActiveProject,
|
||||
isPaused: updated.isPaused,
|
||||
})}\n\n`
|
||||
)
|
||||
)
|
||||
|
||||
// Check pause/resume transitions
|
||||
if (updated.isPaused && !lastIsPaused) {
|
||||
controller.enqueue(encoder.encode(`event: session.paused\ndata: {}\n\n`))
|
||||
} else if (!updated.isPaused && lastIsPaused) {
|
||||
controller.enqueue(encoder.encode(`event: session.resumed\ndata: {}\n\n`))
|
||||
}
|
||||
|
||||
lastActiveProjectId = updated.activeProjectId
|
||||
lastIsPaused = updated.isPaused
|
||||
}
|
||||
|
||||
// Poll cohort changes
|
||||
const currentCohorts = await prisma.cohort.findMany({
|
||||
where: { stageId: cursor.stageId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isOpen: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
projects: { select: { projectId: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const currentCohortState = JSON.stringify(
|
||||
currentCohorts.map((c) => ({
|
||||
id: c.id,
|
||||
isOpen: c.isOpen,
|
||||
windowOpenAt: c.windowOpenAt?.toISOString() ?? null,
|
||||
windowCloseAt: c.windowCloseAt?.toISOString() ?? null,
|
||||
}))
|
||||
)
|
||||
|
||||
if (currentCohortState !== lastCohortState) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`event: cohort.window.changed\ndata: ${JSON.stringify({
|
||||
openCohorts: currentCohorts.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isOpen: c.isOpen,
|
||||
projectIds: c.projects.map((p) => p.projectId),
|
||||
})),
|
||||
})}\n\n`
|
||||
)
|
||||
)
|
||||
lastCohortState = currentCohortState
|
||||
}
|
||||
|
||||
// Send heartbeat to keep connection alive
|
||||
controller.enqueue(encoder.encode(`: heartbeat\n\n`))
|
||||
} catch {
|
||||
// Connection may be closed, ignore errors
|
||||
}
|
||||
}, POLL_INTERVAL_MS)
|
||||
})
|
||||
},
|
||||
cancel() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Check if client disconnected
|
||||
request.signal.addEventListener('abort', () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
}
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const POLL_INTERVAL_MS = 2000
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const { sessionId } = await params
|
||||
|
||||
// Validate session exists
|
||||
const cursor = await prisma.liveProgressCursor.findUnique({
|
||||
where: { sessionId },
|
||||
})
|
||||
|
||||
if (!cursor) {
|
||||
return new Response('Session not found', { status: 404 })
|
||||
}
|
||||
|
||||
// Manually fetch related data since LiveProgressCursor doesn't have these relations
|
||||
let activeProject = null
|
||||
if (cursor.activeProjectId) {
|
||||
activeProject = await prisma.project.findUnique({
|
||||
where: { id: cursor.activeProjectId },
|
||||
select: { id: true, title: true, teamName: true, description: true },
|
||||
})
|
||||
}
|
||||
|
||||
const stageInfo = await prisma.stage.findUnique({
|
||||
where: { id: cursor.stageId },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Send initial state
|
||||
type CohortWithProjects = Awaited<ReturnType<typeof prisma.cohort.findMany<{
|
||||
where: { stageId: string }
|
||||
include: { projects: { select: { projectId: true } } }
|
||||
}>>>
|
||||
|
||||
const cohortPromise: Promise<CohortWithProjects> = prisma.cohort
|
||||
.findMany({
|
||||
where: { stageId: cursor.stageId },
|
||||
include: {
|
||||
projects: {
|
||||
select: { projectId: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((cohorts) => {
|
||||
const initData = {
|
||||
activeProject,
|
||||
isPaused: cursor.isPaused,
|
||||
stageInfo,
|
||||
openCohorts: cohorts.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isOpen: c.isOpen,
|
||||
projectIds: c.projects.map((p) => p.projectId),
|
||||
})),
|
||||
}
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: init\ndata: ${JSON.stringify(initData)}\n\n`)
|
||||
)
|
||||
|
||||
return cohorts
|
||||
})
|
||||
.catch((): CohortWithProjects => {
|
||||
// Ignore errors on init
|
||||
return []
|
||||
})
|
||||
|
||||
cohortPromise.then((initialCohorts: CohortWithProjects) => {
|
||||
// Poll for updates
|
||||
let lastActiveProjectId = cursor.activeProjectId
|
||||
let lastIsPaused = cursor.isPaused
|
||||
let lastCohortState = JSON.stringify(
|
||||
(initialCohorts ?? []).map((c: { id: string; isOpen: boolean; windowOpenAt: Date | null; windowCloseAt: Date | null }) => ({
|
||||
id: c.id,
|
||||
isOpen: c.isOpen,
|
||||
windowOpenAt: c.windowOpenAt?.toISOString() ?? null,
|
||||
windowCloseAt: c.windowCloseAt?.toISOString() ?? null,
|
||||
}))
|
||||
)
|
||||
|
||||
intervalId = setInterval(async () => {
|
||||
try {
|
||||
const updated = await prisma.liveProgressCursor.findUnique({
|
||||
where: { sessionId },
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`event: session.ended\ndata: ${JSON.stringify({ reason: 'Session removed' })}\n\n`
|
||||
)
|
||||
)
|
||||
controller.close()
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for cursor changes
|
||||
if (
|
||||
updated.activeProjectId !== lastActiveProjectId ||
|
||||
updated.isPaused !== lastIsPaused
|
||||
) {
|
||||
// Fetch updated active project if changed
|
||||
let updatedActiveProject = null
|
||||
if (updated.activeProjectId) {
|
||||
updatedActiveProject = await prisma.project.findUnique({
|
||||
where: { id: updated.activeProjectId },
|
||||
select: { id: true, title: true, teamName: true, description: true },
|
||||
})
|
||||
}
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`event: cursor.updated\ndata: ${JSON.stringify({
|
||||
activeProject: updatedActiveProject,
|
||||
isPaused: updated.isPaused,
|
||||
})}\n\n`
|
||||
)
|
||||
)
|
||||
|
||||
// Check pause/resume transitions
|
||||
if (updated.isPaused && !lastIsPaused) {
|
||||
controller.enqueue(encoder.encode(`event: session.paused\ndata: {}\n\n`))
|
||||
} else if (!updated.isPaused && lastIsPaused) {
|
||||
controller.enqueue(encoder.encode(`event: session.resumed\ndata: {}\n\n`))
|
||||
}
|
||||
|
||||
lastActiveProjectId = updated.activeProjectId
|
||||
lastIsPaused = updated.isPaused
|
||||
}
|
||||
|
||||
// Poll cohort changes
|
||||
const currentCohorts = await prisma.cohort.findMany({
|
||||
where: { stageId: cursor.stageId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isOpen: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
projects: { select: { projectId: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const currentCohortState = JSON.stringify(
|
||||
currentCohorts.map((c) => ({
|
||||
id: c.id,
|
||||
isOpen: c.isOpen,
|
||||
windowOpenAt: c.windowOpenAt?.toISOString() ?? null,
|
||||
windowCloseAt: c.windowCloseAt?.toISOString() ?? null,
|
||||
}))
|
||||
)
|
||||
|
||||
if (currentCohortState !== lastCohortState) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`event: cohort.window.changed\ndata: ${JSON.stringify({
|
||||
openCohorts: currentCohorts.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isOpen: c.isOpen,
|
||||
projectIds: c.projects.map((p) => p.projectId),
|
||||
})),
|
||||
})}\n\n`
|
||||
)
|
||||
)
|
||||
lastCohortState = currentCohortState
|
||||
}
|
||||
|
||||
// Send heartbeat to keep connection alive
|
||||
controller.enqueue(encoder.encode(`: heartbeat\n\n`))
|
||||
} catch {
|
||||
// Connection may be closed, ignore errors
|
||||
}
|
||||
}, POLL_INTERVAL_MS)
|
||||
})
|
||||
},
|
||||
cancel() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Check if client disconnected
|
||||
request.signal.addEventListener('abort', () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,125 +1,125 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { LocalStorageProvider } from '@/lib/storage/local-provider'
|
||||
import { getContentType } from '@/lib/storage'
|
||||
import * as fs from 'fs/promises'
|
||||
|
||||
const provider = new LocalStorageProvider()
|
||||
|
||||
/**
|
||||
* Handle GET requests for file downloads
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const key = searchParams.get('key')
|
||||
const action = searchParams.get('action')
|
||||
const expires = searchParams.get('expires')
|
||||
const sig = searchParams.get('sig')
|
||||
|
||||
// Validate required parameters
|
||||
if (!key || !action || !expires || !sig) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameters' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify signature and expiry
|
||||
const isValid = LocalStorageProvider.verifySignature(
|
||||
key,
|
||||
action,
|
||||
parseInt(expires),
|
||||
sig
|
||||
)
|
||||
|
||||
if (!isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or expired signature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (action !== 'download') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action for GET request' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = provider.getAbsoluteFilePath(key)
|
||||
const data = await fs.readFile(filePath)
|
||||
const contentType = getContentType(key)
|
||||
|
||||
return new NextResponse(data, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
console.error('Error serving file:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle PUT requests for file uploads
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const key = searchParams.get('key')
|
||||
const action = searchParams.get('action')
|
||||
const expires = searchParams.get('expires')
|
||||
const sig = searchParams.get('sig')
|
||||
|
||||
// Validate required parameters
|
||||
if (!key || !action || !expires || !sig) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameters' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify signature and expiry
|
||||
const isValid = LocalStorageProvider.verifySignature(
|
||||
key,
|
||||
action,
|
||||
parseInt(expires),
|
||||
sig
|
||||
)
|
||||
|
||||
if (!isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or expired signature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (action !== 'upload') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action for PUT request' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = request.headers.get('content-type') || 'application/octet-stream'
|
||||
const data = Buffer.from(await request.arrayBuffer())
|
||||
|
||||
await provider.putObject(key, data, contentType)
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { LocalStorageProvider } from '@/lib/storage/local-provider'
|
||||
import { getContentType } from '@/lib/storage'
|
||||
import * as fs from 'fs/promises'
|
||||
|
||||
const provider = new LocalStorageProvider()
|
||||
|
||||
/**
|
||||
* Handle GET requests for file downloads
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const key = searchParams.get('key')
|
||||
const action = searchParams.get('action')
|
||||
const expires = searchParams.get('expires')
|
||||
const sig = searchParams.get('sig')
|
||||
|
||||
// Validate required parameters
|
||||
if (!key || !action || !expires || !sig) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameters' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify signature and expiry
|
||||
const isValid = LocalStorageProvider.verifySignature(
|
||||
key,
|
||||
action,
|
||||
parseInt(expires),
|
||||
sig
|
||||
)
|
||||
|
||||
if (!isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or expired signature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (action !== 'download') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action for GET request' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = provider.getAbsoluteFilePath(key)
|
||||
const data = await fs.readFile(filePath)
|
||||
const contentType = getContentType(key)
|
||||
|
||||
return new NextResponse(data, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
console.error('Error serving file:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle PUT requests for file uploads
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const key = searchParams.get('key')
|
||||
const action = searchParams.get('action')
|
||||
const expires = searchParams.get('expires')
|
||||
const sig = searchParams.get('sig')
|
||||
|
||||
// Validate required parameters
|
||||
if (!key || !action || !expires || !sig) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameters' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify signature and expiry
|
||||
const isValid = LocalStorageProvider.verifySignature(
|
||||
key,
|
||||
action,
|
||||
parseInt(expires),
|
||||
sig
|
||||
)
|
||||
|
||||
if (!isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or expired signature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (action !== 'upload') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action for PUT request' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = request.headers.get('content-type') || 'application/octet-stream'
|
||||
const data = Buffer.from(await request.arrayBuffer())
|
||||
|
||||
await provider.putObject(key, data, contentType)
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user