Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s

This commit is contained in:
Matt
2026-02-14 15:26:42 +01:00
parent e56e143a40
commit b5425e705e
374 changed files with 116737 additions and 111969 deletions

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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',
},
})
}

View File

@@ -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',
},
})
}

View File

@@ -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 }
)
}
}